Compare commits

..

1 Commits

Author SHA1 Message Date
Fredrik Burmester
0e73299429 Revert "feat: haptic feedback settings and custom hook" 2025-01-05 15:51:04 +01:00
377 changed files with 10240 additions and 26111 deletions

View File

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

View File

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

1
.gitattributes vendored
View File

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

View File

@@ -43,13 +43,7 @@ body:
label: Version label: Version
description: What version of Streamyfin are you running? description: What version of Streamyfin are you running?
options: options:
- 0.28.0
- 0.27.0
- 0.26.1
- 0.26.0
- 0.25.0
- 0.24.0 - 0.24.0
- 0.23.0
- 0.22.0 - 0.22.0
- 0.21.0 - 0.21.0
- older - older

View File

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

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

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

View File

@@ -1,85 +0,0 @@
name: 🤖 iOS IPA Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
# push:
# branches: [develop]
jobs:
build:
runs-on: macos-15
name: 🏗️ Build iOS IPA
permissions:
contents: read
steps:
- name: 📥 Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
# @todo: update to 1.x once this is fixed: https://github.com/streamyfin/streamyfin/pull/690#discussion_r2089749689
bun-version: '1.2.13'
- name: 💾 Cache Bun dependencies
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-
- name: 📦 Install & Prepare
run: |
bun i && bun run submodule-reload
npx expo prebuild
- name: 🏗️ Build iOS app
uses: sparkfabrik/ios-build-action@be021d9f600b104d199a500db7ba479149a6b257 # v2.3.2
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
- name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
- name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: streamyfin-ipa-${{ github.sha }}-${{ env.DATE_TAG }}
path: build-*.ipa
retention-days: 7

View File

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

View File

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

View File

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

View File

@@ -1,96 +0,0 @@
name: 🚦 Security & Quality Gate
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
branches: [develop, master]
workflow_dispatch:
permissions:
contents: read
jobs:
validate_pr_title:
name: "📝 Validate PR Title"
runs-on: ubuntu-24.04
permissions:
pull-requests: write
contents: read
steps:
- uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
**Error details:**
```
${{ steps.lint_pr_title.outputs.error_message }}
```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
delete: true
dependency-review:
name: 🔍 Vulnerable Dependencies
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Dependency Review
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
with:
fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- "lint"
- "check"
steps:
- name: "📥 Checkout PR code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
fetch-depth: 0
- name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '20.x'
- name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
# @todo: update to 1.x once this is fixed: https://github.com/streamyfin/streamyfin/pull/690#discussion_r2089749689
bun-version: '1.2.13'
- 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@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
with:
args: |
📢 New Pull Request in **${{ github.repository }}**
**Title:** ${{ github.event.pull_request.title }}
**By:** ${{ github.event.pull_request.user.login }}
**Branch:** ${{ github.event.pull_request.head.ref }}
🔗 ${{ github.event.pull_request.html_url }}

View File

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

16
.gitignore vendored
View File

@@ -10,7 +10,6 @@ npm-debug.*
*.orig.* *.orig.*
web-build/ web-build/
modules/vlc-player/android/build modules/vlc-player/android/build
modules/vlc-player/android/.gradle
# macOS # macOS
.DS_Store .DS_Store
@@ -19,16 +18,14 @@ expo-env.d.ts
Streamyfin.app Streamyfin.app
build-*
*.mp4 *.mp4
build-*
Streamyfin.app Streamyfin.app
package-lock.json package-lock.json
/ios /ios
/android /android
/iostv
/iosmobile
/androidmobile
/androidtv
modules/player/android modules/player/android
@@ -38,11 +35,4 @@ credentials.json
*.ipa *.ipa
.continuerc.json .continuerc.json
.vscode/ .vscode/
.idea/
.ruby-lsp
modules/hls-downloader/android/build
streamyfin-4fec1-firebase-adminsdk.json
.env
.env.local
*.aab

View File

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

3
.idea/.gitignore generated vendored Normal file
View File

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

329
.idea/caches/deviceStreaming.xml generated Normal file
View File

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

6
.idea/misc.xml generated Normal file
View File

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

8
.idea/modules.xml generated Normal file
View File

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

9
.idea/streamyfin.iml generated Normal file
View File

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

6
.idea/vcs.xml generated Normal file
View File

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

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

136
README.md
View File

@@ -2,24 +2,23 @@
<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 video streaming client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox. Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
<div style="display: flex; flex-direction: row; gap: 8px"> <div style="display: flex; flex-direction: row; gap: 8px">
<img width=150 src="./assets/images/screenshots/screenshot1.png" /> <img width=150 src="./assets/images/screenshots/screenshot1.png" />
<img width=150 src="./assets/images/screenshots/screenshot3.png" /> <img width=150 src="./assets/images/screenshots/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" /> <img width=150 src="./assets/images/screenshots/screenshot2.png" />
<img width=159 src="./assets/images/jellyseerr.PNG"/>
</div> </div>
## 🌟 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.
- 🤖 **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
@@ -31,19 +30,24 @@ Downloading works by using ffmpeg to convert an HLS stream into a video file on
### 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 ## Plugins
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: In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
- Auto log in to Jellyseerr without the user having to do anything ### Collection rows
- Choose the default languages
- Set download method and search provider
- Customize home screen
- And more...
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin) Jellyfin collections can be shown as rows or carousel on the home screen.
The following tags can be added to a collection to provide this functionality.
Available tags:
- sf_promoted: will make the collection a row at home
- sf_carousel: will make the collection a carousel on home.
A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc.
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
### Jellysearch ### Jellysearch
@@ -66,9 +70,9 @@ Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/r
### 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'll 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.
## 🚀 Getting Started ## 🚀 Getting Started
@@ -85,15 +89,8 @@ We welcome any help to make Streamyfin better. If you'd like to contribute, plea
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. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
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.
For the TV version suffix the npm commands with `:tv`.
`npm run prebuild:tv`
`npm run ios:tv or npm run android:tv`
## 📄 License ## 📄 License
@@ -118,102 +115,13 @@ 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. I'd like to thank the following people and projects for their contributions to Streamyfin:
Special shoutout to the JF official clients for being an inspiration to ours.
### Core Developers
Thanks to the following contributors for their significant contributions:
<table>
<tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center">
<a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub>
</a>
</td>
</tr>
</table>
And all other developers who have contributed to Streamyfin, thank you for your contributions.
I'd also like to thank the following people and projects for their contributions to Streamyfin:
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API. - [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK. - [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.

View File

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

View File

@@ -2,11 +2,16 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.28.0", "version": "0.24.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#2E2E2E"
},
"jsEngine": "hermes", "jsEngine": "hermes",
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
@@ -27,33 +32,31 @@
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
}, },
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin", "bundleIdentifier": "com.fredrikburmester.streamyfin"
"icon": {
"dark": "./assets/images/icon-plain.png",
"light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png"
}
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 55, "versionCode": 50,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-plain.png", "foregroundImage": "./assets/images/adaptive_icon.png"
"monochromeImage": "./assets/images/icon-mono.png",
"backgroundColor": "#464646"
}, },
"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",
"expo-router", "expo-router",
"expo-font", "expo-font",
"@config-plugins/ffmpeg-kit-react-native",
[
"react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
[ [
"react-native-video", "react-native-video",
{ {
@@ -75,19 +78,18 @@
"useFrameworks": "static" "useFrameworks": "static"
}, },
"android": { "android": {
"compileSdkVersion": 35, "android": {
"targetSdkVersion": 35, "compileSdkVersion": 34,
"buildToolsVersion": "35.0.0", "targetSdkVersion": 34,
"kotlinVersion": "2.0.21", "buildToolsVersion": "34.0.0"
},
"minSdkVersion": 24, "minSdkVersion": 24,
"usesCleartextTraffic": true, "usesCleartextTraffic": true,
"packagingOptions": { "packagingOptions": {
"jniLibs": { "jniLibs": {
"useLegacyPackaging": true "useLegacyPackaging": true
} }
}, }
"useAndroidX": true,
"enableJetifier": true
} }
} }
], ],
@@ -103,37 +105,13 @@
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching." "motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
} }
], ],
"expo-localization",
"expo-asset", "expo-asset",
[ [
"react-native-edge-to-edge", "react-native-edge-to-edge",
{ { "android": { "parentTheme": "Material3" } }
"android": {
"parentTheme": "Material3"
}
}
], ],
["react-native-bottom-tabs"], ["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"]
["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"],
["./plugins/withRNBackgroundDownloader.js"],
[
"expo-splash-screen",
{
"backgroundColor": "#2e2e2e",
"image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100
}
],
[
"expo-notifications",
{
"icon": "./assets/images/notification.png",
"color": "#9333EA"
}
]
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
@@ -146,13 +124,12 @@
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68" "projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
} }
}, },
"owner": "streamyfin", "owner": "fredrikburmester",
"runtimeVersion": { "runtimeVersion": {
"policy": "appVersion" "policy": "appVersion"
}, },
"updates": { "updates": {
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68" "url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
}, }
"newArchEnabled": false
} }
} }

View File

@@ -1,17 +1,15 @@
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";
export default function CustomMenuLayout() { export default function CustomMenuLayout() {
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,
headerTitle: t("tabs.custom_links"), headerTitle: "Custom Links",
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -1,15 +1,12 @@
import { Text } from "@/components/common/Text";
import { ListItem } from "@/components/list/ListItem";
import { apiAtom } from "@/providers/JellyfinProvider";
import Ionicons from "@expo/vector-icons/Ionicons";
import { useAtom } from "jotai/index";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { FlatList, TouchableOpacity, View } from "react-native"; import { FlatList, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import React, { useCallback, useEffect, useState } from "react";
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null; import { useAtom } from "jotai/index";
import { apiAtom } from "@/providers/JellyfinProvider";
import { ListItem } from "@/components/list/ListItem";
import * as WebBrowser from "expo-web-browser";
import Ionicons from "@expo/vector-icons/Ionicons";
import { Text } from "@/components/common/Text";
export interface MenuLink { export interface MenuLink {
name: string; name: string;
@@ -21,16 +18,15 @@ export default function menuLinks() {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]); const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
const { t } = useTranslation();
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;
} }
@@ -46,7 +42,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,
@@ -54,16 +50,10 @@ export default function menuLinks() {
}} }}
data={menuLinks} data={menuLinks}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity <TouchableOpacity onPress={() => WebBrowser.openBrowserAsync(item.url)}>
onPress={() => {
if (!Platform.isTV) {
WebBrowser.openBrowserAsync(item.url);
}
}}
>
<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>
)} )}
@@ -76,10 +66,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">No links</Text>
{t("custom_links.no_links")}
</Text>
</View> </View>
} }
/> />

View File

@@ -1,23 +1,21 @@
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
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";
export default function SearchLayout() { export default function SearchLayout() {
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,
headerTitle: t("tabs.favorites"), headerTitle: "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

@@ -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,155 +1,95 @@
import { Chromecast } from "@/components/Chromecast";
import { Text } from "@/components/common/Text";
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack"; import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
import { Feather, Ionicons } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { Stack, useRouter } from "expo-router"; import { Stack, useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
import { userAtom } from "@/providers/JellyfinProvider";
import { useAtom } from "jotai";
export default function IndexLayout() { export default function IndexLayout() {
const router = useRouter(); const router = useRouter();
const [user] = useAtom(userAtom);
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,
headerTitle: t("tabs.home"), headerTitle: "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 && ( <Chromecast />
<> <TouchableOpacity
<Chromecast.Chromecast /> onPress={() => {
{user?.Policy?.IsAdministrator && <SessionsButton />} router.push("/(auth)/settings");
<SettingsButton /> }}
</> >
)} <Feather name="settings" color={"white"} size={22} />
</TouchableOpacity>
</View> </View>
), ),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='downloads/index' name="downloads/index"
options={{ options={{
title: t("home.downloads.downloads_title"), title: "Downloads",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='downloads/[seriesId]' name="downloads/[seriesId]"
options={{ options={{
title: t("home.downloads.tvseries"), title: "TV-Series",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='sessions/index' name="settings"
options={{ options={{
title: t("home.sessions.title"), title: "Settings",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings' name="settings/optimized-server/page"
options={{
title: t("home.settings.settings_title"),
}}
/>
<Stack.Screen
name='settings/optimized-server/page'
options={{ options={{
title: "", title: "",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/marlin-search/page' name="settings/marlin-search/page"
options={{ options={{
title: "", title: "",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/jellyseerr/page' name="settings/jellyseerr/page"
options={{ options={{
title: "", title: "",
}} }}
/> />
<Stack.Screen <Stack.Screen
name='settings/hide-libraries/page' name="settings/popular-lists/page"
options={{ options={{
title: "", title: "",
}} }}
/> />
<Stack.Screen
name='settings/logs/page'
options={{
title: "",
}}
/>
<Stack.Screen
name='intro/page'
options={{
headerShown: false,
title: "",
presentation: "modal",
}}
/>
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => ( {Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
<Stack.Screen key={name} name={name} options={options} /> <Stack.Screen key={name} name={name} options={options} />
))} ))}
<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 { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import {
SeasonDropdown,
type SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View, Alert } from "react-native";
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
SeasonDropdown,
SeasonIndexState,
} from "@/components/series/SeasonDropdown";
import { storage } from "@/utils/mmkv";
import { Ionicons } from "@expo/vector-icons";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
@@ -21,7 +21,7 @@ export default function page() {
}; };
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>( const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}, {}
); );
const { downloadedFiles, deleteItems } = useDownload(); const { downloadedFiles, deleteItems } = useDownload();
@@ -29,9 +29,9 @@ export default function page() {
try { try {
return ( return (
downloadedFiles 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 {
@@ -64,7 +64,7 @@ export default function page() {
() => () =>
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ?? Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
series?.[0]?.item?.ParentIndexNumber, series?.[0]?.item?.ParentIndexNumber,
[groupBySeason], [groupBySeason]
); );
useEffect(() => { useEffect(() => {
@@ -92,14 +92,14 @@ 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={series.map((s) => s.item)} seasons={series.map((s) => s.item)}
@@ -112,17 +112,17 @@ export default function page() {
})); }));
}} }}
/> />
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'> <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,32 +1,29 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads"; import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize";
import { MovieCard } from "@/components/downloads/MovieCard"; import { MovieCard } from "@/components/downloads/MovieCard";
import { SeriesCard } from "@/components/downloads/SeriesCard"; import { SeriesCard } from "@/components/downloads/SeriesCard";
import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider"; import { DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log";
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 { 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 { t } from "i18next";
import { useAtom } from "jotai";
import React, { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { writeToLog } from "@/utils/log";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const { removeProcess, downloadedFiles, deleteFileByType } = useDownload(); const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
const router = useRouter(); const router = useRouter();
@@ -45,7 +42,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) => {
@@ -73,25 +70,17 @@ export default function page() {
const deleteMovies = () => const deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => .then(() => toast.success("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("Failed to delete all movies");
}); });
const deleteShows = () => const deleteShows = () =>
deleteFileByType("Episode") deleteFileByType("Episode")
.then(() => .then(() => toast.success("Deleted all TV-Series 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("Failed to delete all TV-Series");
}); });
const deleteAllMedia = async () => const deleteAllMedia = async () =>
await Promise.all([deleteMovies(), deleteShows()]); await Promise.all([deleteMovies(), deleteShows()]);
@@ -105,28 +94,26 @@ export default function page() {
paddingBottom: 100, paddingBottom: 100,
}} }}
> >
<View className='py-4'> <View className="py-4">
<View className='mb-4 flex flex-col space-y-4 px-4'> <View className="mb-4 flex flex-col space-y-4 px-4">
{settings?.downloadMethod === DownloadMethod.Remux && ( {settings?.downloadMethod === "remux" && (
<View className='bg-neutral-900 p-4 rounded-2xl'> <View className="bg-neutral-900 p-4 rounded-2xl">
<Text className='text-lg font-bold'> <Text className="text-lg font-bold">Queue</Text>
{t("home.downloads.queue")} <Text className="text-xs opacity-70 text-red-600">
Queue and active downloads will be lost on app restart
</Text> </Text>
<Text className='text-xs opacity-70 text-red-600'> <View className="flex flex-col space-y-2 mt-2">
{t("home.downloads.queue_hint")}
</Text>
<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>
@@ -139,16 +126,14 @@ 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">No items in queue</Text>
{t("home.downloads.no_items_in_queue")}
</Text>
)} )}
</View> </View>
)} )}
@@ -157,19 +142,17 @@ export default function page() {
</View> </View>
{movies.length > 0 && ( {movies.length > 0 && (
<View className='mb-4'> <View className="mb-4">
<View className='flex flex-row items-center justify-between mb-2 px-4'> <View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className='text-lg font-bold'> <Text className="text-lg font-bold">Movies</Text>
{t("home.downloads.movies")} <View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
</Text> <Text className="text-xs font-bold">{movies?.length}</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>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'> <View className="px-4 flex flex-row">
{movies?.map((item) => ( {movies?.map((item) => (
<View className='mb-2 last:mb-0' key={item.item.Id}> <View className="mb-2 last:mb-0" key={item.item.Id}>
<MovieCard item={item.item} /> <MovieCard item={item.item} />
</View> </View>
))} ))}
@@ -178,22 +161,20 @@ export default function page() {
</View> </View>
)} )}
{groupedBySeries.length > 0 && ( {groupedBySeries.length > 0 && (
<View className='mb-4'> <View className="mb-4">
<View className='flex flex-row items-center justify-between mb-2 px-4'> <View className="flex flex-row items-center justify-between mb-2 px-4">
<Text className='text-lg font-bold'> <Text className="text-lg font-bold">TV-Series</Text>
{t("home.downloads.tvseries")} <View className="bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center">
</Text> <Text className="text-xs font-bold">
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length} {groupedBySeries?.length}
</Text> </Text>
</View> </View>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}> <ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'> <View className="px-4 flex flex-row">
{groupedBySeries?.map((items) => ( {groupedBySeries?.map((items) => (
<View <View
className='mb-2 last:mb-0' className="mb-2 last:mb-0"
key={items[0].item.SeriesId} key={items[0].item.SeriesId}
> >
<SeriesCard <SeriesCard
@@ -207,10 +188,8 @@ export default function page() {
</View> </View>
)} )}
{downloadedFiles?.length === 0 && ( {downloadedFiles?.length === 0 && (
<View className='flex px-4'> <View className="flex px-4">
<Text className='opacity-50'> <Text className="opacity-50">No downloaded items</Text>
{t("home.downloads.no_downloaded_items")}
</Text>
</View> </View>
)} )}
</View> </View>
@@ -233,15 +212,15 @@ 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")} Delete all Movies
</Button> </Button>
<Button color='purple' onPress={deleteShows}> <Button color="purple" onPress={deleteShows}>
{t("home.downloads.delete_all_tvseries_button")} Delete all TV-Series
</Button> </Button>
<Button color='red' onPress={deleteAllMedia}> <Button color="red" onPress={deleteAllMedia}>
{t("home.downloads.delete_all_button")} Delete all
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>
@@ -254,18 +233,18 @@ function migration_20241124() {
const router = useRouter(); const router = useRouter();
const { deleteAllFiles } = useDownload(); const { deleteAllFiles } = useDownload();
Alert.alert( Alert.alert(
t("home.downloads.new_app_version_requires_re_download"), "New app version requires re-download",
t("home.downloads.new_app_version_requires_re_download_description"), "The new update reqires content to be downloaded again. Please remove all downloaded content and try again.",
[ [
{ {
text: t("home.downloads.back"), text: "Back",
onPress: () => router.back(), onPress: () => router.back(),
}, },
{ {
text: t("home.downloads.delete"), text: "Delete",
style: "destructive", style: "destructive",
onPress: async () => await deleteAllFiles(), onPress: async () => await deleteAllFiles(),
}, },
], ]
); );
} }

View File

@@ -1,5 +1,438 @@
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, useQueryClient } from "@tanstack/react-query";
import { useNavigation, useRouter } from "expo-router";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
RefreshControl,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
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 api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [loading, setLoading] = useState(false);
const [settings, _] = useSettings();
const [isConnected, setIsConnected] = useState<boolean | null>(null);
const [loadingRetry, setLoadingRetry] = useState(false);
const { downloadedFiles, cleanCacheDirectory } = useDownload();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
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]);
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: userViews,
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,
});
const {
data: mediaListCollections,
isError: e2,
isLoading: l2,
} = useQuery({
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
queryFn: async () => {
if (!api || !user?.Id) return [];
const response = await getItemsApi(api).getItems({
userId: user.Id,
tags: ["sf_promoted"],
recursive: true,
fields: ["Tags"],
includeItemTypes: ["BoxSet"],
});
return response.data.Items || [];
},
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
staleTime: 60 * 1000,
});
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 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]
);
const sections = useMemo(() => {
if (!api || !user?.Id) return [];
const latestMediaViews = collections.map((c) => {
const includeItemTypes: BaseItemKind[] =
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
const title = "Recently Added in " + c.Name;
const queryKey = [
"home",
"recentlyAddedIn" + c.CollectionType,
user?.Id!,
c.Id!,
];
return createCollectionConfig(
title || "",
queryKey,
includeItemTypes,
c.Id
);
});
const ss: Section[] = [
{
title: "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: "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: "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: "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, mediaListCollections]);
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">No Internet</Text>
<Text className="text-center opacity-70">
No worries, you can still watch{"\n"}downloaded content.
</Text>
<View className="mt-4">
<Button
color="purple"
onPress={() => router.push("/(auth)/downloads")}
justify="center"
iconRight={
<Ionicons name="arrow-forward" size={20} color="white" />
}
>
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 || e2)
return (
<View className="flex flex-col items-center justify-center h-full -mt-6">
<Text className="text-3xl font-bold mb-2">Oops!</Text>
<Text className="text-center opacity-70">
Something went wrong.{"\n"}Please log out and in again.
</Text>
</View>
);
if (l1 || l2)
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}
/>
);
} 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,141 +0,0 @@
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv";
import { Feather, Ionicons } from "@expo/vector-icons";
import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Linking, TouchableOpacity, View } from "react-native";
export default function page() {
const router = useRouter();
const { t } = useTranslation();
useFocusEffect(
useCallback(() => {
storage.set("hasShownIntro", true);
}, []),
);
return (
<View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
<View>
<Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")}
</Text>
<Text className='text-center'>
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
</Text>
</View>
<View>
<Text className='text-lg font-bold'>
{t("home.intro.features_title")}
</Text>
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
<View className='flex flex-row items-center mt-4'>
<Image
source={require("@/assets/icons/jellyseerr-logo.svg")}
style={{
width: 50,
height: 50,
}}
/>
<View className='shrink ml-2'>
<Text className='font-bold mb-1'>Jellyseerr</Text>
<Text className='shrink text-xs'>
{t("home.intro.jellyseerr_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'
>
<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
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")}
</Text>
<Text className='shrink text-xs'>
{t("home.intro.centralised_settings_plugin_description")}{" "}
<Text
className='text-purple-600'
onPress={() => {
Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
);
}}
>
{t("home.intro.read_more")}
</Text>
</Text>
</View>
</View>
</View>
<View>
<Button
onPress={() => {
router.back();
}}
className='mt-4'
>
{t("home.intro.done_button")}
</Button>
<TouchableOpacity
onPress={() => {
router.back();
router.push("/settings");
}}
className='mt-4'
>
<Text className='text-purple-600 text-center'>
{t("home.intro.go_to_settings_button")}
</Text>
</TouchableOpacity>
</View>
</View>
);
}

View File

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

View File

@@ -1,10 +1,8 @@
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 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";
@@ -13,28 +11,22 @@ 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 { UserInfo } from "@/components/settings/UserInfo"; import { UserInfo } from "@/components/settings/UserInfo";
import { useHaptic } from "@/hooks/useHaptic";
import { useJellyfin } from "@/providers/JellyfinProvider"; import { useJellyfin } from "@/providers/JellyfinProvider";
import { userAtom } from "@/providers/JellyfinProvider";
import { clearLogs } from "@/utils/log"; import { clearLogs } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import * as Haptics from "expo-haptics";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next"; import { useEffect } from "react";
import { useAtom } from "jotai"; import { ScrollView, TouchableOpacity, View } from "react-native";
import React, { useEffect } from "react";
import { ScrollView, Switch, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
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 onClearLogsClicked = async () => { const onClearLogsClicked = async () => {
clearLogs(); clearLogs();
successHapticFeedback(); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}; };
const navigation = useNavigation(); const navigation = useNavigation();
@@ -46,9 +38,7 @@ export default function settings() {
logout(); logout();
}} }}
> >
<Text className='text-red-600'> <Text className="text-red-600">Log out</Text>
{t("home.settings.log_out_button")}
</Text>
</TouchableOpacity> </TouchableOpacity>
), ),
}); });
@@ -61,54 +51,32 @@ 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 /> <DownloadSettings />
<PluginSettings /> <PluginSettings />
<AppLanguageSelector /> <View className="mb-4">
<ListGroup title={"Logs"}>
<ChromecastSettings />
<ListGroup title={"Intro"}>
<ListItem
onPress={() => {
router.push("/intro/page");
}}
title={t("home.settings.intro.show_intro")}
/>
<ListItem
textColor='red'
onPress={() => {
storage.set("hasShownIntro", false);
}}
title={t("home.settings.intro.reset_intro")}
/>
</ListGroup>
<View className='mb-4'>
<ListGroup title={t("home.settings.logs.logs_title")}>
<ListItem <ListItem
onPress={() => router.push("/settings/logs/page")} onPress={() => router.push("/settings/logs/page")}
showArrow showArrow
title={t("home.settings.logs.logs_title")} title={"Logs"}
/> />
<ListItem <ListItem
textColor='red' textColor="red"
onPress={onClearLogsClicked} onPress={onClearLogsClicked}
title={t("home.settings.logs.delete_all_logs")} title={"Delete All Logs"}
/> />
</ListGroup> </ListGroup>
</View> </View>

View File

@@ -1,67 +0,0 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup";
import { ListItem } from "@/components/list/ListItem";
import DisabledSetting from "@/components/settings/DisabledSetting";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { useTranslation } from "react-i18next";
import { Switch, View } from "react-native";
export default function page() {
const [settings, updateSettings, pluginSettings] = useSettings();
const user = useAtomValue(userAtom);
const api = useAtomValue(apiAtom);
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id],
queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({
userId: user?.Id,
});
return response.data.Items || null;
},
});
if (!settings) return null;
if (isLoading)
return (
<View className='mt-4'>
<Loader />
</View>
);
return (
<DisabledSetting
disabled={pluginSettings?.hiddenLibraries?.locked === true}
className='px-4'
>
<ListGroup>
{data?.map((view) => (
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
<Switch
value={settings.hiddenLibraries?.includes(view.Id!) || false}
onValueChange={(value) => {
updateSettings({
hiddenLibraries: value
? [...(settings.hiddenLibraries || []), view.Id!]
: settings.hiddenLibraries?.filter((id) => id !== view.Id),
});
}}
/>
</ListItem>
))}
</ListGroup>
<Text className='px-4 text-xs text-neutral-500 mt-1'>
{t("home.settings.other.select_liraries_you_want_to_hide")}
</Text>
</DisabledSetting>
);
}

View File

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

View File

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

View File

@@ -1,13 +1,12 @@
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 { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; 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 { useTranslation } from "react-i18next"; import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import DisabledSetting from "@/components/settings/DisabledSetting";
import React, { useEffect, useMemo, useState } from "react";
import { import {
Linking, Linking,
Switch, Switch,
@@ -20,9 +19,7 @@ import { toast } from "sonner-native";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const [settings, updateSettings] = useSettings();
const [settings, updateSettings, pluginSettings] = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [value, setValue] = useState<string>(settings?.marlinServerUrl || ""); const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
@@ -31,93 +28,76 @@ export default function page() {
updateSettings({ updateSettings({
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1), marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
}); });
toast.success(t("home.settings.plugins.marlin_search.toasts.saved")); toast.success("Saved");
}; };
const handleOpenLink = () => { const handleOpenLink = () => {
Linking.openURL("https://github.com/fredrikburmester/marlin-search"); Linking.openURL("https://github.com/fredrikburmester/marlin-search");
}; };
const disabled = useMemo(() => {
return (
pluginSettings?.searchEngine?.locked === true &&
pluginSettings?.marlinServerUrl?.locked === true
);
}, [pluginSettings]);
useEffect(() => { useEffect(() => {
if (!pluginSettings?.marlinServerUrl?.locked) { navigation.setOptions({
navigation.setOptions({ headerRight: () => (
headerRight: () => ( <TouchableOpacity onPress={() => onSave(value)}>
<TouchableOpacity onPress={() => onSave(value)}> <Text className="text-blue-500">Save</Text>
<Text className='text-blue-500'> </TouchableOpacity>
{t("home.settings.plugins.marlin_search.save_button")} ),
</Text> });
</TouchableOpacity>
),
});
}
}, [navigation, value]); }, [navigation, value]);
if (!settings) return null; if (!settings) return null;
return ( return (
<DisabledSetting disabled={disabled} className='px-4'> <View className="px-4">
<ListGroup> <ListGroup>
<DisabledSetting <ListItem
disabled={pluginSettings?.searchEngine?.locked === true} title={"Enable Marlin Search"}
showText={!pluginSettings?.marlinServerUrl?.locked} onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
> >
<ListItem <Switch
title={t( value={settings.searchEngine === "Marlin"}
"home.settings.plugins.marlin_search.enable_marlin_search", onValueChange={(value) => {
)} updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
onPress={() => {
updateSettings({ searchEngine: "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] }); queryClient.invalidateQueries({ queryKey: ["search"] });
}} }}
> />
<Switch </ListItem>
value={settings.searchEngine === "Marlin"}
onValueChange={(value) => {
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
queryClient.invalidateQueries({ queryKey: ["search"] });
}}
/>
</ListItem>
</DisabledSetting>
</ListGroup> </ListGroup>
<DisabledSetting <View
disabled={pluginSettings?.marlinServerUrl?.locked === true} className={`mt-2 ${
showText={!pluginSettings?.searchEngine?.locked} settings.searchEngine === "Marlin" ? "" : "opacity-50"
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 className="flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4">
<Text className='mr-4'> <View
{t("home.settings.plugins.marlin_search.url")} className={`flex flex-row items-center bg-neutral-900 h-11 pr-4`}
</Text> >
<TextInput <Text className="mr-4">URL</Text>
editable={settings.searchEngine === "Marlin"} <TextInput
className='text-white' editable={settings.searchEngine === "Marlin"}
placeholder={t( className="text-white"
"home.settings.plugins.marlin_search.server_url_placeholder", placeholder="http(s)://domain.org:port"
)} 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> </View>
</DisabledSetting> <Text className="px-4 text-xs text-neutral-500 mt-1">
<Text className='px-4 text-xs text-neutral-500 mt-1'> Enter the URL for the Marlin server. The URL should include http or
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "} https and optionally the port.{" "}
<Text className='text-blue-500' onPress={handleOpenLink}> <Text className="text-blue-500" onPress={handleOpenLink}>
{t("home.settings.plugins.marlin_search.read_more_about_marlin")} Read more about Marlin.
</Text>
</Text> </Text>
</Text> </View>
</DisabledSetting> </View>
); );
} }

View File

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

View File

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

View File

@@ -10,20 +10,18 @@ import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData"; import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api"; import { 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";
const page: React.FC = () => { const page: React.FC = () => {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { actorId } = local as { actorId: string }; const { actorId } = local as { actorId: string };
const { t } = useTranslation();
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
@@ -68,7 +66,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 +77,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,14 +103,14 @@ 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")} Appeared In
</Text> </Text>
<InfiniteHorizontalScroll <InfiniteHorizontalScroll
height={247} height={247}
@@ -133,7 +131,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

@@ -0,0 +1,128 @@
import { Chromecast } from "@/components/Chromecast";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { SongsList } from "@/components/music/SongsList";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import ArtistPoster from "@/components/posters/ArtistPoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId, artistId, albumId } = searchParams as {
collectionId: string;
artistId: string;
albumId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
useEffect(() => {
navigation.setOptions({
headerRight: () => (
<View className="">
<Chromecast />
</View>
),
});
});
const { data: album } = useQuery({
queryKey: ["album", albumId, artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [albumId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!albumId,
staleTime: 0,
});
const {
data: songs,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["songs", artistId, albumId],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: albumId,
fields: [
"ItemCounts",
"PrimaryImageAspectRatio",
"CanDelete",
"MediaSourceCount",
],
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
const insets = useSafeAreaInsets();
if (!album) return null;
return (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={album}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
<Text className="text-neutral-500">
{songs?.TotalRecordCount} songs
</Text>
</View>
<View className="px-4">
<SongsList
albumId={albumId}
songs={songs?.Items}
collectionId={collectionId}
artistId={artistId}
/>
</View>
</ParallaxScrollView>
);
}

View File

@@ -0,0 +1,130 @@
import ArtistPoster from "@/components/posters/ArtistPoster";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai";
import { useEffect, useState } from "react";
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
export default function page() {
const searchParams = useLocalSearchParams();
const { artistId } = searchParams as {
artistId: string;
};
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const navigation = useNavigation();
const [startIndex, setStartIndex] = useState<number>(0);
const { data: artist } = useQuery({
queryKey: ["album", artistId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [artistId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!artistId,
staleTime: 0,
});
const {
data: albums,
isLoading,
isError,
} = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["albums", artistId, startIndex],
queryFn: async () => {
if (!api)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getItemsApi(api).getItems({
userId: user?.Id,
parentId: artistId,
sortOrder: ["Descending", "Descending", "Ascending"],
includeItemTypes: ["MusicAlbum"],
recursive: true,
fields: [
"ParentId",
"PrimaryImageAspectRatio",
"ParentId",
"PrimaryImageAspectRatio",
],
collapseBoxSetItems: false,
albumArtistIds: [artistId],
startIndex,
limit: 100,
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!api && !!user?.Id,
});
const insets = useSafeAreaInsets();
if (!artist || !albums) return null;
return (
<ParallaxScrollView
headerHeight={400}
headerImage={
<ItemImage
variant={"Primary"}
item={artist}
style={{
width: "100%",
height: "100%",
}}
/>
}
>
<View className="px-4 mb-8">
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
<Text className="text-neutral-500">
{albums.TotalRecordCount} albums
</Text>
</View>
<View className="flex flex-row flex-wrap justify-between px-4">
{albums.Items.map((item, idx) => (
<TouchableItemRouter
item={item}
style={{ width: "30%", marginBottom: 20 }}
key={idx}
>
<View className="flex flex-col gap-y-2">
<ArtistPoster item={item} />
<Text numberOfLines={2}>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
))}
</View>
</ParallaxScrollView>
);
}

View File

@@ -0,0 +1,117 @@
import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import ArtistPoster from "@/components/posters/ArtistPoster";
import MoviePoster from "@/components/posters/MoviePoster";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { router, useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import { useMemo, useState } from "react";
import { FlatList, TouchableOpacity, View } from "react-native";
export default function page() {
const searchParams = useLocalSearchParams();
const { collectionId } = searchParams as { collectionId: string };
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data: collection } = useQuery({
queryKey: ["collection", collectionId],
queryFn: async () => {
if (!api) return null;
const response = await getItemsApi(api).getItems({
userId: user?.Id,
ids: [collectionId],
});
const data = response.data.Items?.[0];
return data;
},
enabled: !!api && !!user?.Id && !!collectionId,
staleTime: 0,
});
const [startIndex, setStartIndex] = useState<number>(0);
const { data, isLoading, isError } = useQuery<{
Items: BaseItemDto[];
TotalRecordCount: number;
}>({
queryKey: ["collection-items", collection?.Id, startIndex],
queryFn: async () => {
if (!api || !collectionId)
return {
Items: [],
TotalRecordCount: 0,
};
const response = await getArtistsApi(api).getArtists({
sortBy: ["SortName"],
sortOrder: ["Ascending"],
fields: ["PrimaryImageAspectRatio", "SortName"],
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
parentId: collectionId,
userId: user?.Id,
});
const data = response.data.Items;
return {
Items: data || [],
TotalRecordCount: response.data.TotalRecordCount || 0,
};
},
enabled: !!collection?.Id && !!api && !!user?.Id,
});
const totalItems = useMemo(() => {
return data?.TotalRecordCount;
}, [data]);
if (!data) return null;
return (
<FlatList
contentContainerStyle={{
padding: 16,
paddingBottom: 140,
}}
ListHeaderComponent={
<View className="mb-4">
<Text className="font-bold text-3xl mb-2">Artists</Text>
</View>
}
nestedScrollEnabled
data={data.Items}
numColumns={3}
columnWrapperStyle={{
justifyContent: "space-between",
}}
renderItem={({ item, index }) => (
<TouchableItemRouter
style={{
maxWidth: "30%",
width: "100%",
}}
key={index}
item={item}
>
<View className="flex flex-col gap-y-2">
{collection?.CollectionType === "movies" && (
<MoviePoster item={item} />
)}
{collection?.CollectionType === "music" && (
<ArtistPoster item={item} />
)}
<Text>{item.Name}</Text>
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
</View>
</TouchableItemRouter>
)}
keyExtractor={(item) => item.Id || ""}
/>
);
}

View File

@@ -1,23 +1,22 @@
import { ItemCardText } from "@/components/ItemCardText";
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 { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
SortByOption,
SortOrderOption,
genreFilterAtom, genreFilterAtom,
sortByAtom, sortByAtom,
SortByOption,
sortOptions, sortOptions,
sortOrderAtom, sortOrderAtom,
SortOrderOption,
sortOrderOptions, sortOrderOptions,
tagsFilterAtom, tagsFilterAtom,
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import type { import {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
ItemSortBy, ItemSortBy,
@@ -30,10 +29,9 @@ 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 * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FlatList, View } from "react-native"; import { FlatList, View } from "react-native";
const page: React.FC = () => { const page: React.FC = () => {
@@ -44,11 +42,9 @@ const page: React.FC = () => {
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 [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom); const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom); const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom); const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
@@ -112,8 +108,8 @@ const page: React.FC = () => {
recursive: true, recursive: true,
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)), years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: ["Movie", "Series"], includeItemTypes: ["Movie", "Series", "MusicAlbum"],
}); });
return response.data || null; return response.data || null;
@@ -127,7 +123,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 +148,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 +185,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 +196,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 +229,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,
@@ -247,7 +244,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedGenres} set={setSelectedGenres}
values={selectedGenres} values={selectedGenres}
title={t("library.filters.genres")} title="Genres"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -259,13 +256,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,
@@ -274,7 +271,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedYears} set={setSelectedYears}
values={selectedYears} values={selectedYears}
title={t("library.filters.years")} title="Years"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)} searchFilter={(item, search) => item.includes(search)}
/> />
@@ -284,13 +281,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,
@@ -299,7 +296,7 @@ const page: React.FC = () => {
}} }}
set={setSelectedTags} set={setSelectedTags}
values={selectedTags} values={selectedTags}
title={t("library.filters.tags")} title="Tags"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -311,13 +308,13 @@ 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}
title={t("library.filters.sort_by")} title="Sort By"
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || "" sortOptions.find((i) => i.key === item)?.value || ""
} }
@@ -331,13 +328,13 @@ 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}
title={t("library.filters.sort_order")} title="Sort Order"
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || "" sortOrderOptions.find((i) => i.key === item)?.value || ""
} }
@@ -368,7 +365,7 @@ const page: React.FC = () => {
sortOrder, sortOrder,
setSortOrder, setSortOrder,
isFetching, isFetching,
], ]
); );
if (!collection) return null; if (!collection) return null;
@@ -376,10 +373,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">No results</Text>
{t("search.no_results")}
</Text>
</View> </View>
} }
extraData={[ extraData={[
@@ -389,7 +384,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 +406,7 @@ const page: React.FC = () => {
width: 10, width: 10,
height: 10, height: 10,
}} }}
/> ></View>
)} )}
/> />
); );

View File

@@ -1,13 +1,11 @@
import { ItemContent } from "@/components/ItemContent";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api"; import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import React, { useEffect } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
import Animated, { import Animated, {
runOnJS, runOnJS,
@@ -20,7 +18,6 @@ const Page: React.FC = () => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation();
const { data: item, isError } = useQuery({ const { data: item, isError } = useQuery({
queryKey: ["item", id], queryKey: ["item", id],
@@ -76,36 +73,36 @@ 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>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} />} {item && <ItemContent item={item} />}
</View> </View>

View File

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

View File

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

View File

@@ -1,79 +1,64 @@
import { Button } from "@/components/Button"; import React, { useCallback, useRef, useState } from "react";
import { GenreTags } from "@/components/GenreTags"; import { useLocalSearchParams } from "expo-router";
import { OverviewText } from "@/components/OverviewText"; import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { ParallaxScrollView } from "@/components/ParallaxPage";
import { JellyserrRatings } from "@/components/Ratings";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import Cast from "@/components/jellyseerr/Cast"; import { ParallaxScrollView } from "@/components/ParallaxPage";
import DetailFacts from "@/components/jellyseerr/DetailFacts"; import { Image } from "expo-image";
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons"; import { TouchableOpacity, View} from "react-native";
import { ItemActions } from "@/components/series/SeriesActions";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
import {
type IssueType,
IssueTypeName,
} from "@/utils/jellyseerr/server/constants/issue";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type {
MovieResult,
TvResult,
} from "@/utils/jellyseerr/server/models/Search";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OverviewText } from "@/components/OverviewText";
import { GenreTags } from "@/components/GenreTags";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { useQuery } from "@tanstack/react-query";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { Button } from "@/components/Button";
import { import {
BottomSheetBackdrop, BottomSheetBackdrop,
type BottomSheetBackdropProps, BottomSheetBackdropProps,
BottomSheetModal, BottomSheetModal, BottomSheetTextInput,
BottomSheetTextInput,
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useQuery } from "@tanstack/react-query"; import {
import { Image } from "expo-image"; IssueType,
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router"; IssueTypeName,
import type React from "react"; } from "@/utils/jellyseerr/server/constants/issue";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as DropdownMenu from "zeego/dropdown-menu";
import { useTranslation } from "react-i18next"; import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Platform, TouchableOpacity, View } from "react-native"; import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { JellyserrRatings } from "@/components/Ratings";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
import RequestModal from "@/components/jellyseerr/RequestModal";
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
const Page: React.FC = () => { const Page: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { t } = useTranslation(); const {
const router = useRouter(); mediaTitle,
releaseYear,
canRequest: canRequestString,
posterSrc,
...result
} = params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
} & Partial<MovieResult | TvResult>;
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } = const canRequest = canRequestString === "true";
params as unknown as {
mediaTitle: string;
releaseYear: number;
canRequest: string;
posterSrc: string;
mediaType: MediaType;
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
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 bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { const {
data: details, data: details,
isFetching, isFetching,
isLoading, isLoading,
refetch, refetch
} = useQuery({ } = useQuery({
enabled: !!jellyseerrApi && !!result && !!result.id, enabled: !!jellyseerrApi && !!result && !!result.id,
queryKey: ["jellyseerr", "detail", mediaType, result.id], queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
refetchOnReconnect: true, refetchOnReconnect: true,
@@ -81,15 +66,12 @@ 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!!);
}, },
}); });
const [canRequest, hasAdvancedRequestPermission] =
useJellyseerrCanRequest(details);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop <BottomSheetBackdrop
@@ -98,7 +80,7 @@ const Page: React.FC = () => {
appearsOnIndex={0} appearsOnIndex={0}
/> />
), ),
[], []
); );
const submitIssue = useCallback(() => { const submitIssue = useCallback(() => {
@@ -113,61 +95,32 @@ const Page: React.FC = () => {
} }
}, [jellyseerrApi, details, result, issueType, issueMessage]); }, [jellyseerrApi, details, result, issueType, issueMessage]);
const setRequestBody = useCallback( const request = useCallback(
(body: MediaRequestBody) => { async () => {
_setRequestBody(body); requestMedia(mediaTitle, {
advancedReqModalRef?.current?.present?.(); mediaId: Number(result.id!!),
mediaType: result.mediaType!!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
},
refetch
)
}, },
[requestBody, _setRequestBody, advancedReqModalRef], [details, result, requestMedia]
); );
const request = useCallback(async () => {
const body: MediaRequestBody = {
mediaId: Number(result.id!),
mediaType: mediaType!,
tvdbId: details?.externalIds?.tvdbId,
seasons: (details as TvDetails)?.seasons
?.filter?.((s) => s.seasonNumber !== 0)
?.map?.((s) => s.seasonNumber),
};
if (hasAdvancedRequestPermission) {
setRequestBody(body);
return;
}
requestMedia(mediaTitle, body, refetch);
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
const isAnime = useMemo(
() =>
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
mediaType === MediaType.TV,
[details],
);
useEffect(() => {
if (details) {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
<ItemActions item={details} />
</TouchableOpacity>
),
});
}
}, [details]);
return ( 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>
@@ -180,10 +133,7 @@ const Page: React.FC = () => {
height: "100%", height: "100%",
}} }}
source={{ source={{
uri: jellyseerrApi?.imageProxy( uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
result.backdropPath,
"w1920_and_h800_multi_faces",
),
}} }}
/> />
) : ( ) : (
@@ -192,12 +142,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>
@@ -205,31 +155,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={
result as
| MovieResult
| TvResult
| MovieDetails
| TvDetails
}
/>
<Text <Text
uiTextView uiTextView
selectable selectable
className='font-bold text-2xl mb-1' 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={{
@@ -237,100 +179,43 @@ 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 ? ( {canRequest ? (
<Button <Button color="purple" onPress={request}>
loading={true} Request
disabled={true}
color='purple'
className='mt-4'
/>
) : canRequest ? (
<Button color='purple' onPress={request} className='mt-4'>
{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"
<Button color="transparent"
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100' onPress={() => bottomSheetModalRef?.current?.present()}
color='transparent' iconLeft={
onPress={() => bottomSheetModalRef?.current?.present()} <Ionicons name="warning-outline" size={24} color="white" />
iconLeft={ }
<Ionicons style={{
name='warning-outline' borderWidth: 1,
size={20} borderStyle: "solid",
color='white' }}
/> >
} Report issue
style={{ </Button>
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}
onAdvancedRequest={(data) => setRequestBody(data)}
/> />
)} )}
<DetailFacts
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
details={details}
/>
<Cast details={details} />
</View> </View>
</View> </View>
</ParallaxScrollView> </ParallaxScrollView>
<RequestModal
ref={advancedReqModalRef}
requestBody={requestBody}
title={mediaTitle}
id={result.id!}
type={mediaType}
isAnime={isAnime}
onRequested={() => {
_setRequestBody(undefined);
advancedReqModalRef?.current?.close();
refetch();
}}
onDismiss={() => _setRequestBody(undefined)}
/>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing
@@ -343,41 +228,39 @@ const Page: React.FC = () => {
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">
{t("jellyseerr.whats_wrong")} Whats wrong?
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 items-start'> <View className="flex flex-col space-y-2 items-start">
<View className='flex flex-col'> <View className="flex flex-col">
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<View className='flex flex-col'> <View className="flex flex-col">
<Text className='opacity-50 mb-1 text-xs'> <Text className="opacity-50 mb-1 text-xs">
{t("jellyseerr.issue_type")} Issue Type
</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}>
{issueType {issueType
? IssueTypeName[issueType] ? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")} : "Select an issue"}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</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}
sideOffset={0} sideOffset={0}
> >
<DropdownMenu.Label> <DropdownMenu.Label>Types</DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName) {Object.entries(IssueTypeName)
.reverse() .reverse()
.map(([key, value], idx) => ( .map(([key, value], idx) => (
@@ -396,14 +279,16 @@ const Page: React.FC = () => {
</DropdownMenu.Root> </DropdownMenu.Root>
</View> </View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'> <View
className="p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full"
>
<BottomSheetTextInput <BottomSheetTextInput
multiline multiline
maxLength={254} maxLength={254}
style={{ color: "white" }} style={{color: "white"}}
clearButtonMode='always' clearButtonMode="always"
placeholder={t("jellyseerr.describe_the_issue")} placeholder="(optional) Describe the issue..."
placeholderTextColor='#9CA3AF' placeholderTextColor="#9CA3AF"
// Issue with multiline + Textinput inside a portal // Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668 // https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage} defaultValue={issueMessage}
@@ -411,8 +296,8 @@ const Page: React.FC = () => {
/> />
</View> </View>
</View> </View>
<Button className='mt-auto' onPress={submitIssue} color='purple'> <Button className="mt-auto" onPress={submitIssue} color="purple">
{t("jellyseerr.submit_button")} Submit
</Button> </Button>
</View> </View>
</BottomSheetView> </BottomSheetView>

View File

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

View File

@@ -3,10 +3,7 @@ import type {
MaterialTopTabNavigationOptions, MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs"; } from "@react-navigation/material-top-tabs";
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs"; import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
import type { import { ParamListBase, TabNavigationState } from "@react-navigation/native";
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router"; import { Stack, withLayoutContext } from "expo-router";
import React from "react"; import React from "react";
@@ -24,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 },
@@ -40,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

@@ -31,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",
@@ -47,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

@@ -9,7 +9,6 @@ import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useMemo, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
Button, Button,
Dimensions, Dimensions,
@@ -71,7 +70,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,
@@ -100,7 +99,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,
@@ -117,16 +116,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%",
@@ -148,7 +147,7 @@ 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
@@ -178,16 +177,15 @@ const PageButtons: React.FC<PageButtonsProps> = ({
onNextPage, onNextPage,
isNextDisabled, isNextDisabled,
}) => { }) => {
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"}
/> />
@@ -196,22 +194,22 @@ const PageButtons: React.FC<PageButtonsProps> = ({
currentPage === 1 ? "text-gray-500" : "text-white" currentPage === 1 ? "text-gray-500" : "text-white"
}`} }`}
> >
{t("live_tv.previous")} 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"}`}
> >
{t("live_tv.next")} 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,10 @@
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList"; import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { TAB_HEIGHT } from "@/constants/Values"; import { TAB_HEIGHT } from "@/constants/Values";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api"; import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
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";
@@ -14,12 +13,10 @@ export default function page() {
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { t } = useTranslation();
return ( return (
<ScrollView <ScrollView
nestedScrollEnabled nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior="automatic"
key={"home"} key={"home"}
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
@@ -28,10 +25,10 @@ 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={"On now"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({ const res = await getLiveTvApi(api).getRecommendedPrograms({
@@ -45,11 +42,11 @@ export default function page() {
}); });
return res.data.Items || []; return res.data.Items || [];
}} }}
orientation='horizontal' orientation="horizontal"
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "shows"]} queryKey={["livetv", "shows"]}
title={t("live_tv.shows")} title={"Shows"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -67,11 +64,11 @@ export default function page() {
}); });
return res.data.Items || []; return res.data.Items || [];
}} }}
orientation='horizontal' orientation="horizontal"
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "movies"]} queryKey={["livetv", "movies"]}
title={t("live_tv.movies")} title={"Movies"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -85,11 +82,11 @@ export default function page() {
}); });
return res.data.Items || []; return res.data.Items || [];
}} }}
orientation='horizontal' orientation="horizontal"
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "sports"]} queryKey={["livetv", "sports"]}
title={t("live_tv.sports")} title={"Sports"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -103,11 +100,11 @@ export default function page() {
}); });
return res.data.Items || []; return res.data.Items || [];
}} }}
orientation='horizontal' orientation="horizontal"
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "kids"]} queryKey={["livetv", "kids"]}
title={t("live_tv.for_kids")} title={"For Kids"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -121,11 +118,11 @@ export default function page() {
}); });
return res.data.Items || []; return res.data.Items || [];
}} }}
orientation='horizontal' orientation="horizontal"
/> />
<ScrollingCollectionList <ScrollingCollectionList
queryKey={["livetv", "news"]} queryKey={["livetv", "news"]}
title={t("live_tv.news")} title={"News"}
queryFn={async () => { queryFn={async () => {
if (!api) return [] as BaseItemDto[]; if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({ const res = await getLiveTvApi(api).getLiveTvPrograms({
@@ -139,7 +136,7 @@ export default function page() {
}); });
return res.data.Items || []; return res.data.Items || [];
}} }}
orientation='horizontal' orientation="horizontal"
/> />
</View> </View>
</ScrollView> </ScrollView>

View File

@@ -1,13 +1,11 @@
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native"; import { View } from "react-native";
export default function page() { export default function page() {
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>Coming soon</Text>
</View> </View>
); );
} }

View File

@@ -14,14 +14,11 @@ import { useQuery } from "@tanstack/react-query";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import React, { useEffect, useMemo } from "react";
import { useEffect, useMemo } from "react"; import { View } from "react-native";
import { useTranslation } from "react-i18next";
import { Platform, View } from "react-native";
const page: React.FC = () => { const page: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const { id: seriesId, seasonIndex } = params as { const { id: seriesId, seasonIndex } = params as {
id: string; id: string;
@@ -50,7 +47,7 @@ const page: React.FC = () => {
quality: 90, quality: 90,
width: 1000, width: 1000,
}), }),
[item], [item]
); );
const logoUrl = useMemo( const logoUrl = useMemo(
@@ -59,7 +56,7 @@ const page: React.FC = () => {
api, api,
item, item,
}), }),
[item], [item]
); );
const { data: allEpisodes, isLoading } = useQuery({ const { data: allEpisodes, isLoading } = useQuery({
@@ -84,25 +81,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="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>
), ),
}); });
@@ -125,23 +120,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",
/> }}
) : null />
) : 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,35 +1,35 @@
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
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 * as ScreenOrientation from "expo-screen-orientation";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useCallback, useEffect, useMemo } from "react"; import React, { useCallback, useEffect, useMemo } from "react";
import { FlatList, View, useWindowDimensions } from "react-native"; import { FlatList, useWindowDimensions, View } from "react-native";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton"; import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
import { ItemCardText } from "@/components/ItemCardText";
import { Loader } from "@/components/Loader";
import { ItemPoster } from "@/components/posters/ItemPoster"; import { ItemPoster } from "@/components/posters/ItemPoster";
import { useOrientation } from "@/hooks/useOrientation"; import { useOrientation } from "@/hooks/useOrientation";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { import {
SortByOption,
SortOrderOption,
genreFilterAtom, genreFilterAtom,
getSortByPreference, getSortByPreference,
getSortOrderPreference, getSortOrderPreference,
sortByAtom, sortByAtom,
SortByOption,
sortByPreferenceAtom, sortByPreferenceAtom,
sortOptions, sortOptions,
sortOrderAtom, sortOrderAtom,
SortOrderOption,
sortOrderOptions, sortOrderOptions,
sortOrderPreferenceAtom, sortOrderPreferenceAtom,
tagsFilterAtom, tagsFilterAtom,
yearFilterAtom, yearFilterAtom,
} from "@/utils/atoms/filters"; } from "@/utils/atoms/filters";
import type { import {
BaseItemDto, BaseItemDto,
BaseItemDtoQueryResult, BaseItemDtoQueryResult,
BaseItemKind, BaseItemKind,
@@ -40,7 +40,6 @@ 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 { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
const Page = () => { const Page = () => {
@@ -58,13 +57,11 @@ 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();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const sop = getSortOrderPreference(libraryId, sortOrderPreference); const sop = getSortOrderPreference(libraryId, sortOrderPreference);
if (sop) { if (sop) {
@@ -88,7 +85,7 @@ const Page = () => {
} }
_setSortBy(sortBy); _setSortBy(sortBy);
}, },
[libraryId, sortByPreference], [libraryId, sortByPreference]
); );
const setSortOrder = useCallback( const setSortOrder = useCallback(
@@ -102,7 +99,7 @@ const Page = () => {
} }
_setSortOrder(sortOrder); _setSortOrder(sortOrder);
}, },
[libraryId, sortOrderPreference], [libraryId, sortOrderPreference]
); );
const nrOfCols = useMemo(() => { const nrOfCols = useMemo(() => {
@@ -153,6 +150,8 @@ const Page = () => {
itemType = "Series"; itemType = "Series";
} else if (library.CollectionType === "boxsets") { } else if (library.CollectionType === "boxsets") {
itemType = "BoxSet"; itemType = "BoxSet";
} else if (library.CollectionType === "music") {
itemType = "MusicAlbum";
} }
const response = await getItemsApi(api).getItems({ const response = await getItemsApi(api).getItems({
@@ -169,7 +168,7 @@ const Page = () => {
fields: ["PrimaryImageAspectRatio", "SortName"], fields: ["PrimaryImageAspectRatio", "SortName"],
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year)), years: selectedYears.map((year) => parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined, includeItemTypes: itemType ? [itemType] : undefined,
}); });
@@ -185,7 +184,7 @@ const Page = () => {
selectedTags, selectedTags,
sortBy, sortBy,
sortOrder, sortOrder,
], ]
); );
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } = const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
@@ -211,13 +210,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,
@@ -247,8 +247,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%",
}} }}
@@ -259,14 +259,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}
@@ -285,13 +285,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,
@@ -300,7 +300,7 @@ const Page = () => {
}} }}
set={setSelectedGenres} set={setSelectedGenres}
values={selectedGenres} values={selectedGenres}
title={t("library.filters.genres")} title="Genres"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -312,13 +312,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,
@@ -327,7 +327,7 @@ const Page = () => {
}} }}
set={setSelectedYears} set={setSelectedYears}
values={selectedYears} values={selectedYears}
title={t("library.filters.years")} title="Years"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => item.includes(search)} searchFilter={(item, search) => item.includes(search)}
/> />
@@ -337,13 +337,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,
@@ -352,7 +352,7 @@ const Page = () => {
}} }}
set={setSelectedTags} set={setSelectedTags}
values={selectedTags} values={selectedTags}
title={t("library.filters.tags")} title="Tags"
renderItemLabel={(item) => item.toString()} renderItemLabel={(item) => item.toString()}
searchFilter={(item, search) => searchFilter={(item, search) =>
item.toLowerCase().includes(search.toLowerCase()) item.toLowerCase().includes(search.toLowerCase())
@@ -364,21 +364,13 @@ 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 () => queryFn={async () => sortOptions.map((s) => s.key)}
sortOptions
.filter(
(s) =>
library?.CollectionType !== "movies" ||
s.key !== SortByOption.DateLastContentAdded,
)
.map((s) => s.key)
}
set={setSortBy} set={setSortBy}
values={sortBy} values={sortBy}
title={t("library.filters.sort_by")} title="Sort By"
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOptions.find((i) => i.key === item)?.value || "" sortOptions.find((i) => i.key === item)?.value || ""
} }
@@ -392,13 +384,13 @@ 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}
title={t("library.filters.sort_order")} title="Sort Order"
renderItemLabel={(item) => renderItemLabel={(item) =>
sortOrderOptions.find((i) => i.key === item)?.value || "" sortOrderOptions.find((i) => i.key === item)?.value || ""
} }
@@ -429,29 +421,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">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">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]}
@@ -476,7 +473,7 @@ const Page = () => {
width: 10, width: 10,
height: 10, height: 10,
}} }}
/> ></View>
)} )}
/> />
); );

View File

@@ -3,204 +3,195 @@ 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";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; import * as DropdownMenu from "zeego/dropdown-menu";
import { useTranslation } from "react-i18next";
export default function IndexLayout() { export default function IndexLayout() {
const [settings, updateSettings, pluginSettings] = useSettings(); const [settings, updateSettings] = useSettings();
const { t } = useTranslation();
if (!settings?.libraryOptions) return null; if (!settings?.libraryOptions) return null;
return ( return (
<Stack> <Stack>
<Stack.Screen <Stack.Screen
name='index' name="index"
options={{ options={{
headerShown: true, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.library"), headerTitle: "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 && <DropdownMenu.Root>
!Platform.isTV && ( <DropdownMenu.Trigger>
<DropdownMenu.Root> <Ionicons
<DropdownMenu.Trigger> name="ellipsis-horizontal-outline"
<Ionicons size={24}
name='ellipsis-horizontal-outline' color="white"
size={24} />
color='white' </DropdownMenu.Trigger>
/> <DropdownMenu.Content
</DropdownMenu.Trigger> align={"end"}
<DropdownMenu.Content alignOffset={-10}
align={"end"} avoidCollisions={false}
alignOffset={-10} collisionPadding={0}
avoidCollisions={false} loop={false}
collisionPadding={0} side={"bottom"}
loop={false} sideOffset={10}
side={"bottom"} >
sideOffset={10} <DropdownMenu.Label>Display</DropdownMenu.Label>
> <DropdownMenu.Group key="display-group">
<DropdownMenu.Label> <DropdownMenu.Sub>
{t("library.options.display")} <DropdownMenu.SubTrigger key="image-style-trigger">
</DropdownMenu.Label> Display
<DropdownMenu.Group key='display-group'> </DropdownMenu.SubTrigger>
<DropdownMenu.Sub> <DropdownMenu.SubContent
<DropdownMenu.SubTrigger key='image-style-trigger'> alignOffset={-10}
{t("library.options.display")} avoidCollisions={true}
</DropdownMenu.SubTrigger> collisionPadding={0}
<DropdownMenu.SubContent loop={true}
alignOffset={-10} sideOffset={10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='display-option-1'
value={settings.libraryOptions.display === "row"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "row",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-1'>
{t("library.options.row")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='display-option-2'
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='display-title-2'>
{t("library.options.list")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key='image-style-trigger'>
{t("library.options.image_style")}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
>
<DropdownMenu.CheckboxItem
key='poster-option'
value={
settings.libraryOptions.imageStyle === "poster"
}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='poster-title'>
{t("library.options.poster")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key='cover-option'
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key='cover-title'>
{t("library.options.cover")}
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key='show-titles-group'>
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key='show-titles-option'
value={settings.libraryOptions.showTitles}
onValueChange={(newValue: string) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on",
},
});
}}
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.CheckboxItem
<DropdownMenu.ItemTitle key='show-titles-title'> key="display-option-1"
{t("library.options.show_titles")} value={settings.libraryOptions.display === "row"}
</DropdownMenu.ItemTitle> onValueChange={() =>
</DropdownMenu.CheckboxItem> updateSettings({
<DropdownMenu.CheckboxItem libraryOptions: {
key='show-stats-option' ...settings.libraryOptions,
value={settings.libraryOptions.showStats} display: "row",
onValueChange={(newValue: string) => { },
updateSettings({ })
libraryOptions: { }
...settings.libraryOptions, >
showStats: newValue === "on", <DropdownMenu.ItemIndicator />
}, <DropdownMenu.ItemTitle key="display-title-1">
}); Row
}} </DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="display-option-2"
value={settings.libraryOptions.display === "list"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
display: "list",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="display-title-2">
List
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger key="image-style-trigger">
Image style
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
alignOffset={-10}
avoidCollisions={true}
collisionPadding={0}
loop={true}
sideOffset={10}
> >
<DropdownMenu.ItemIndicator /> <DropdownMenu.CheckboxItem
<DropdownMenu.ItemTitle key='show-stats-title'> key="poster-option"
{t("library.options.show_stats")} value={settings.libraryOptions.imageStyle === "poster"}
</DropdownMenu.ItemTitle> onValueChange={() =>
</DropdownMenu.CheckboxItem> updateSettings({
</DropdownMenu.Group> libraryOptions: {
...settings.libraryOptions,
imageStyle: "poster",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="poster-title">
Poster
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="cover-option"
value={settings.libraryOptions.imageStyle === "cover"}
onValueChange={() =>
updateSettings({
libraryOptions: {
...settings.libraryOptions,
imageStyle: "cover",
},
})
}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="cover-title">
Cover
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Group>
<DropdownMenu.Group key="show-titles-group">
<DropdownMenu.CheckboxItem
disabled={settings.libraryOptions.imageStyle === "poster"}
key="show-titles-option"
value={settings.libraryOptions.showTitles}
onValueChange={(newValue) => {
if (settings.libraryOptions.imageStyle === "poster")
return;
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showTitles: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-titles-title">
Show titles
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
key="show-stats-option"
value={settings.libraryOptions.showStats}
onValueChange={(newValue) => {
updateSettings({
libraryOptions: {
...settings.libraryOptions,
showStats: newValue === "on" ? true : false,
},
});
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle key="show-stats-title">
Show stats
</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
), ),
}} }}
/> />
<Stack.Screen <Stack.Screen
name='[libraryId]' name="[libraryId]"
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,
}} }}
/> />
@@ -208,12 +199,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: true, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios" ? true : false,
headerShadowVisible: false, headerShadowVisible: false,
}} }}
/> />

View File

@@ -1,6 +1,6 @@
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { LibraryItemCard } from "@/components/library/LibraryItemCard"; import { LibraryItemCard } from "@/components/library/LibraryItemCard";
import { Loader } from "@/components/Loader";
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 { import {
@@ -10,8 +10,7 @@ import {
import { FlashList } from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo } from "react"; import { useEffect } from "react";
import { 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";
@@ -21,29 +20,23 @@ export default function index() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [settings] = useSettings(); const [settings] = useSettings();
const { t } = useTranslation(); const { data, isLoading: isLoading } = useQuery({
const { data, isLoading } = useQuery({
queryKey: ["user-views", user?.Id], queryKey: ["user-views", user?.Id],
queryFn: async () => { queryFn: async () => {
const response = await getUserViewsApi(api!).getUserViews({ if (!api || !user?.Id) {
userId: user?.Id, return null;
}
const response = await getUserViewsApi(api).getUserViews({
userId: user.Id,
}); });
return response.data.Items || null; return response.data.Items || null;
}, },
staleTime: 60, enabled: !!api && !!user?.Id,
staleTime: 60 * 1000 * 60,
}); });
const libraries = useMemo(
() =>
data
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
.filter((l) => l.CollectionType !== "music")
.filter((l) => l.CollectionType !== "books") || [],
[data, settings?.hiddenLibraries],
);
useEffect(() => { useEffect(() => {
for (const item of data || []) { for (const item of data || []) {
queryClient.prefetchQuery({ queryClient.prefetchQuery({
@@ -65,24 +58,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 (!data)
return ( return (
<View className='h-full w-full flex justify-center items-center'> <View className="h-full w-full flex justify-center items-center">
<Text className='text-lg text-neutral-500'> <Text className="text-lg text-neutral-500">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,
@@ -90,7 +81,7 @@ export default function index() {
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
data={libraries} data={data}
renderItem={({ item }) => <LibraryItemCard library={item} />} renderItem={({ item }) => <LibraryItemCard library={item} />}
keyExtractor={(item) => item.Id || ""} keyExtractor={(item) => item.Id || ""}
ItemSeparatorComponent={() => ItemSeparatorComponent={() =>
@@ -99,10 +90,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

@@ -3,24 +3,22 @@ import {
nestedTabPageScreenOptions, nestedTabPageScreenOptions,
} from "@/components/stacks/NestedTabPageStack"; } from "@/components/stacks/NestedTabPageStack";
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";
export default function SearchLayout() { export default function SearchLayout() {
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,
headerTitle: t("tabs.search"), headerTitle: "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,28 +26,16 @@ 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: true, 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
name='jellyseerr/person/[personId]'
options={commonScreenOptions}
/>
<Stack.Screen
name='jellyseerr/company/[companyId]'
options={commonScreenOptions}
/>
<Stack.Screen
name='jellyseerr/genre/[genreId]'
options={commonScreenOptions}
/>
</Stack> </Stack>
); );
} }

View File

@@ -1,42 +1,42 @@
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import { Input } from "@/components/common/Input";
import { Tag } from "@/components/GenreTags";
import { ItemCardText } from "@/components/ItemCardText";
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 { import { ItemCardText } from "@/components/ItemCardText";
JellyseerrSearchSort, import { Loader } from "@/components/Loader";
JellyserrIndexPage, import AlbumCover from "@/components/posters/AlbumCover";
} 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 { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
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 { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
import type { import {
BaseItemDto, BaseItemDto,
BaseItemKind, BaseItemKind,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api"; import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import axios from "axios";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { import React, {
PropsWithChildren,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import { Tag } from "@/components/GenreTags";
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
import { sortBy } from "lodash";
type SearchType = "Library" | "Discover"; type SearchType = "Library" | "Discover";
@@ -53,11 +53,7 @@ export default function search() {
const params = useLocalSearchParams(); const params = useLocalSearchParams();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [user] = useAtom(userAtom); const { q, prev } = params as { q: string; prev: Href<string> };
const { t } = useTranslation();
const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
@@ -65,27 +61,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(
@@ -96,94 +82,63 @@ 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({ if (Platform.OS === "ios")
headerSearchBarOptions: { navigation.setOptions({
ref: searchBarRef, headerSearchBarOptions: {
placeholder: t("search.search"), placeholder: "Search...",
onChangeText: (e: any) => { onChangeText: (e: any) => {
router.setParams({ q: "" }); router.setParams({ q: "" });
setSearch(e.nativeEvent.text); setSearch(e.nativeEvent.text);
},
hideWhenScrolling: false,
autoFocus: true,
}, },
hideWhenScrolling: false, });
autoFocus: false,
},
});
}, [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: () =>
@@ -194,6 +149,48 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
queryKey: ["search", "jellyseerrResults", debouncedSearch],
queryFn: async () => {
const response = await jellyseerrApi?.search({
query: new URLSearchParams(debouncedSearch).toString(),
page: 1, // todo: maybe rework page & page-size if first results are not enough...
language: "en",
});
return response?.results;
},
enabled:
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length > 0,
});
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled:
!!jellyseerrApi &&
searchType === "Discover" &&
debouncedSearch.length == 0,
});
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.MOVIE
) as MovieResult[],
[jellyseerrResults]
);
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
() =>
jellyseerrResults?.filter(
(r) => r.mediaType === MediaType.TV
) as TvResult[],
[jellyseerrResults]
);
const { data: series, isFetching: l2 } = useQuery({ const { data: series, isFetching: l2 } = useQuery({
queryKey: ["search", "series", debouncedSearch], queryKey: ["search", "series", debouncedSearch],
queryFn: () => queryFn: () =>
@@ -234,45 +231,94 @@ export default function search() {
enabled: searchType === "Library" && debouncedSearch.length > 0, enabled: searchType === "Library" && debouncedSearch.length > 0,
}); });
const { data: artists, isFetching: l4 } = useQuery({
queryKey: ["search", "artists", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: albums, isFetching: l5 } = useQuery({
queryKey: ["search", "albums", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: songs, isFetching: l6 } = useQuery({
queryKey: ["search", "songs", debouncedSearch],
queryFn: () =>
searchFn({
query: debouncedSearch,
types: ["Audio"],
}),
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const noResults = useMemo(() => { const noResults = useMemo(() => {
return !( return !(
artists?.length ||
albums?.length ||
songs?.length ||
movies?.length || movies?.length ||
episodes?.length || episodes?.length ||
series?.length || series?.length ||
collections?.length || collections?.length ||
actors?.length actors?.length ||
jellyseerrMovieResults?.length ||
jellyseerrTvResults?.length
); );
}, [episodes, movies, series, collections, actors]); }, [
artists,
episodes,
albums,
songs,
movies,
series,
collections,
actors,
jellyseerrResults,
]);
const loading = useMemo(() => { const loading = useMemo(() => {
return l1 || l2 || l3 || l7 || l8; return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
}, [l1, l2, l3, l7, l8]); }, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
return ( return (
<> <>
<ScrollView <ScrollView
keyboardDismissMode='on-drag' keyboardDismissMode="on-drag"
contentInsetAdjustmentBehavior='automatic' contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ contentContainerStyle={{
paddingLeft: insets.left, paddingLeft: insets.left,
paddingRight: insets.right, paddingRight: insets.right,
}} }}
> >
<View <View className="flex flex-col pt-2">
className='flex flex-col' {Platform.OS === "android" && (
style={{ <View className="mb-4 px-4">
marginTop: Platform.OS === "android" ? 16 : 0, <Input
}} autoCorrect={false}
> returnKeyType="done"
keyboardType="web-search"
placeholder="Search here..."
value={search}
onChangeText={(text) => setSearch(text)}
/>
</View>
)}
{jellyseerrApi && ( {jellyseerrApi && (
<ScrollView <View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
horizontal
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
>
<TouchableOpacity onPress={() => setSearchType("Library")}> <TouchableOpacity onPress={() => setSearchType("Library")}>
<Tag <Tag
text={t("search.library")} text="Library"
textClass='p-1' textClass="p-1"
className={ className={
searchType === "Library" ? "bg-purple-600" : undefined searchType === "Library" ? "bg-purple-600" : undefined
} }
@@ -280,101 +326,70 @@ export default function search() {
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType("Discover")}> <TouchableOpacity onPress={() => setSearchType("Discover")}>
<Tag <Tag
text={t("search.discover")} text="Discover"
textClass='p-1' textClass="p-1"
className={ className={
searchType === "Discover" ? "bg-purple-600" : undefined searchType === "Discover" ? "bg-purple-600" : undefined
} }
/> />
</TouchableOpacity> </TouchableOpacity>
{searchType === "Discover" && </View>
!loading &&
noResults &&
debouncedSearch.length > 0 && (
<View className='flex flex-row justify-end items-center space-x-1'>
<FilterButton
id='search'
queryKey='jellyseerr_search'
queryFn={async () =>
Object.keys(JellyseerrSearchSort).filter((v) =>
Number.isNaN(Number(v)),
)
}
set={(value) => setJellyseerrOrderBy(value[0])}
values={[jellyseerrOrderBy]}
title={t("library.filters.sort_by")}
renderItemLabel={(item) =>
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
}
showSearch={false}
/>
<FilterButton
id='order'
queryKey='jellysearr_search'
queryFn={async () => ["asc", "desc"]}
set={(value) => setJellyseerrSortOrder(value[0])}
values={[jellyseerrSortOrder]}
title={t("library.filters.sort_order")}
renderItemLabel={(item) => t(`library.filters.${item}`)}
showSearch={false}
/>
</View>
)}
</ScrollView>
)} )}
{!!q && (
<View className='mt-2'> <View className="px-4 flex flex-col space-y-2">
<LoadingSkeleton isLoading={loading} /> <Text className="text-neutral-500 ">
</View> Results for <Text className="text-purple-600">{q}</Text>
</Text>
{searchType === "Library" ? ( </View>
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}> )}
{searchType === "Library" && (
<>
<SearchItemWrapper <SearchItemWrapper
header={t("search.movies")} header="Movies"
items={movies} ids={movies?.map((m) => m.Id!)}
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
key={item.Id} key={item.Id}
className='flex flex-col w-28 mr-2' className="flex flex-col w-28 mr-2"
item={item} item={item}
> >
<MoviePoster item={item} key={item.Id} /> <MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'> <Text numberOfLines={2} className="mt-2">
{item.Name} {item.Name}
</Text> </Text>
<Text className='opacity-50 text-xs'> <Text className="opacity-50 text-xs">
{item.ProductionYear} {item.ProductionYear}
</Text> </Text>
</TouchableItemRouter> </TouchableItemRouter>
)} )}
/> />
<SearchItemWrapper <SearchItemWrapper
items={series} ids={series?.map((m) => m.Id!)}
header={t("search.series")} header="Series"
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
key={item.Id} key={item.Id}
item={item} item={item}
className='flex flex-col w-28 mr-2' className="flex flex-col w-28 mr-2"
> >
<SeriesPoster item={item} key={item.Id} /> <SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'> <Text numberOfLines={2} className="mt-2">
{item.Name} {item.Name}
</Text> </Text>
<Text className='opacity-50 text-xs'> <Text className="opacity-50 text-xs">
{item.ProductionYear} {item.ProductionYear}
</Text> </Text>
</TouchableItemRouter> </TouchableItemRouter>
)} )}
/> />
<SearchItemWrapper <SearchItemWrapper
items={episodes} ids={episodes?.map((m) => m.Id!)}
header={t("search.episodes")} header="Episodes"
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
key={item.Id} key={item.Id}
className='flex flex-col w-44 mr-2' className="flex flex-col w-44 mr-2"
> >
<ContinueWatchingPoster item={item} /> <ContinueWatchingPoster item={item} />
<ItemCardText item={item} /> <ItemCardText item={item} />
@@ -382,72 +397,197 @@ export default function search() {
)} )}
/> />
<SearchItemWrapper <SearchItemWrapper
items={collections} ids={collections?.map((m) => m.Id!)}
header={t("search.collections")} header="Collections"
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
key={item.Id} key={item.Id}
item={item} item={item}
className='flex flex-col w-28 mr-2' className="flex flex-col w-28 mr-2"
> >
<MoviePoster item={item} key={item.Id} /> <MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'> <Text numberOfLines={2} className="mt-2">
{item.Name} {item.Name}
</Text> </Text>
</TouchableItemRouter> </TouchableItemRouter>
)} )}
/> />
<SearchItemWrapper <SearchItemWrapper
items={actors} ids={actors?.map((m) => m.Id!)}
header={t("search.actors")} header="Actors"
renderItem={(item: BaseItemDto) => ( renderItem={(item: BaseItemDto) => (
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
key={item.Id} key={item.Id}
className='flex flex-col w-28 mr-2' className="flex flex-col w-28 mr-2"
> >
<MoviePoster item={item} /> <MoviePoster item={item} />
<ItemCardText item={item} /> <ItemCardText item={item} />
</TouchableItemRouter> </TouchableItemRouter>
)} )}
/> />
</View> <SearchItemWrapper
) : ( ids={artists?.map((m) => m.Id!)}
<JellyserrIndexPage header="Artists"
searchQuery={debouncedSearch} renderItem={(item: BaseItemDto) => (
sortType={jellyseerrOrderBy} <TouchableItemRouter
order={jellyseerrSortOrder} item={item}
/> key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</>
)}
{searchType === "Discover" && (
<>
<SearchItemWrapper
header="Request Movies"
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
header="Request Series"
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
</>
)} )}
{searchType === "Library" && {loading ? (
(!loading && noResults && debouncedSearch.length > 0 ? ( <View className="mt-4 flex justify-center items-center">
<View> <Loader />
<Text className='text-center text-lg font-bold mt-4'> </View>
{t("search.no_results_found_for")} ) : noResults && debouncedSearch.length > 0 ? (
</Text> <View>
<Text className='text-xs text-purple-600 text-center'> <Text className="text-center text-lg font-bold mt-4">
"{debouncedSearch}" No results found for
</Text> </Text>
</View> <Text className="text-xs text-purple-600 text-center">
) : debouncedSearch.length === 0 ? ( "{debouncedSearch}"
<View className='mt-4 flex flex-col items-center space-y-2'> </Text>
{exampleSearches.map((e) => ( </View>
<TouchableOpacity ) : debouncedSearch.length === 0 && searchType === "Library" ? (
onPress={() => { <View className="mt-4 flex flex-col items-center space-y-2">
setSearch(e); {exampleSearches.map((e) => (
searchBarRef.current?.setText(e); <TouchableOpacity
}} onPress={() => setSearch(e)}
key={e} key={e}
className='mb-2' className="mb-2"
> >
<Text className='text-purple-600'>{e}</Text> <Text className="text-purple-600">{e}</Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
) : null)} ) : debouncedSearch.length === 0 && searchType === "Discover" ? (
<View className="flex flex-col">
{sortBy?.(
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
"order"
).map((slide) => (
<DiscoverSlide key={slide.id} slide={slide} />
))}
</View>
) : null}
</View> </View>
</ScrollView> </ScrollView>
</> </>
); );
} }
type Props<T> = {
ids?: string[] | null;
items?: T[];
renderItem: (item: any) => React.ReactNode;
header?: string;
};
const SearchItemWrapper = <T extends unknown>({
ids,
items,
renderItem,
header,
}: PropsWithChildren<Props<T>>) => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { data, isLoading: l1 } = useQuery({
queryKey: ["items", ids],
queryFn: async () => {
if (!user?.Id || !api || !ids || ids.length === 0) {
return [];
}
const itemPromises = ids.map((id) =>
getUserItemData({
api,
userId: user.Id,
itemId: id,
})
);
const results = await Promise.all(itemPromises);
// Filter out null items
return results.filter(
(item) => item !== null
) as unknown as BaseItemDto[];
},
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
staleTime: Infinity,
});
if (!data && (!items || items.length === 0)) return null;
return (
<>
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
<ScrollView
horizontal
className="px-4 mb-2"
showsHorizontalScrollIndicator={false}
>
{data && data?.length > 0
? data.map((item) => renderItem(item))
: items && items?.length > 0
? items.map((i) => renderItem(i))
: undefined}
</ScrollView>
</>
);
};

View File

@@ -1,26 +1,24 @@
import React, { useCallback, useRef } from "react"; import React from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; import { withLayoutContext } from "expo-router";
import { import {
type NativeBottomTabNavigationEventMap,
createNativeBottomTabNavigator, createNativeBottomTabNavigator,
NativeBottomTabNavigationEventMap,
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
const { Navigator } = createNativeBottomTabNavigator(); const { Navigator } = createNativeBottomTabNavigator();
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv";
import type { import type {
ParamListBase, ParamListBase,
TabNavigationState, TabNavigationState,
} from "@react-navigation/native"; } from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { useSettings } from "@/utils/atoms/settings";
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
BottomTabNavigationOptions, BottomTabNavigationOptions,
@@ -31,48 +29,23 @@ export const NativeTabs = withLayoutContext<
export default function TabLayout() { export default function TabLayout() {
const [settings] = useSettings(); const [settings] = useSettings();
const { t } = useTranslation();
const router = useRouter();
useFocusEffect(
useCallback(() => {
const hasShownIntro = storage.getBoolean("hasShownIntro");
if (!hasShownIntro) {
const timer = setTimeout(() => {
router.push("/intro/page");
}, 1000);
return () => {
clearTimeout(timer);
};
}
}, []),
);
return ( return (
<> <>
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style="light" />
<NativeTabs <NativeTabs
sidebarAdaptable={false} sidebarAdaptable
ignoresTopSafeArea ignoresTopSafeArea
tabBarStyle={{ barTintColor={Platform.OS === "android" ? "#121212" : undefined}
backgroundColor: "#121212",
}}
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={({ navigation }) => ({ name="(home)"
tabPress: (e) => {
eventBus.emit("scrollToTop");
},
})}
name='(home)'
options={{ options={{
title: t("tabs.home"), title: "Home",
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png") require("@/assets/icons/house.fill.png")
: ({ focused }) => : ({ focused }) =>
@@ -82,16 +55,11 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
listeners={({ navigation }) => ({ name="(search)"
tabPress: (e) => {
eventBus.emit("searchTabPressed");
},
})}
name='(search)'
options={{ options={{
title: t("tabs.search"), title: "Search",
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png") require("@/assets/icons/magnifyingglass.png")
: ({ focused }) => : ({ focused }) =>
@@ -101,11 +69,11 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name='(favorites)' name="(favorites)"
options={{ options={{
title: t("tabs.favorites"), title: "Favorites",
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
focused focused
? require("@/assets/icons/heart.fill.png") ? require("@/assets/icons/heart.fill.png")
@@ -117,11 +85,11 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name='(libraries)' name="(libraries)"
options={{ options={{
title: t("tabs.library"), title: "Library",
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? ({ color, focused, size }) => ? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png") require("@/assets/icons/server.rack.png")
: ({ focused }) => : ({ focused }) =>
@@ -131,13 +99,13 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
name='(custom-links)' name="(custom-links)"
options={{ options={{
title: t("tabs.custom_links"), title: "Custom Links",
// @ts-expect-error // @ts-expect-error
tabBarItemHidden: !settings?.showCustomMenuLinks, tabBarItemHidden: settings?.showCustomMenuLinks ? false : true,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS == "android"
? ({ focused }) => require("@/assets/icons/list.png") ? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) => : ({ focused }) =>
focused focused

View File

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

View File

@@ -1,89 +1,76 @@
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Loader } from "@/components/Loader";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener"; import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic"; import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
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,
PlaybackStatePayload, PlaybackStatePayload,
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types"; } from "@/modules/vlc-player/src/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings"; import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
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 generateDeviceProfile from "@/utils/profiles/native"; import native from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
import { import { Api } from "@jellyfin/sdk";
type BaseItemDto, import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
type MediaSourceInfo,
PlaybackOrder,
type PlaybackProgressInfo,
PlaybackStartInfo,
RepeatMode,
} from "@jellyfin/sdk/lib/generated-client";
import { import {
getPlaystateApi, getPlaystateApi,
getUserLibraryApi, getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api"; } from "@jellyfin/sdk/lib/utils/api";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useQuery } from "@tanstack/react-query";
import { useGlobalSearchParams, useNavigation } from "expo-router"; import * as Haptics from "expo-haptics";
import { useFocusEffect, useGlobalSearchParams } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import React, { import React, {
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useEffect,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import {
import { Alert, Platform, View } from "react-native"; Alert,
BackHandler,
View,
AppState,
AppStateStatus,
Platform,
} from "react-native";
import { useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import settings from "../(tabs)/(home)/settings";
const downloadProvider = !Platform.isTV import { useSettings } from "@/utils/atoms/settings";
? require("@/providers/DownloadProvider")
: null;
export default function page() { export default function page() {
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);
const { t } = useTranslation();
const navigation = useNavigation();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false); const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(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 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
? null
: require("react-native-volume-manager");
let getDownloadedItem = null;
if (!Platform.isTV) {
getDownloadedItem = downloadProvider.useDownload();
}
const { getDownloadedItem } = useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const lightHapticFeedback = useHaptic("light");
const setShowControls = useCallback((show: boolean) => { const setShowControls = useCallback((show: boolean) => {
_setShowControls(show); _setShowControls(show);
lightHapticFeedback(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []); }, []);
const { const {
@@ -102,140 +89,145 @@ export default function page() {
offline: string; offline: string;
}>(); }>();
const [settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
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 [itemStatus, setItemStatus] = useState({ data: item,
isLoading: true, isLoading: isLoadingItem,
isError: false, isError: isErrorItem,
}); } = useQuery({
queryKey: ["item", itemId],
useEffect(() => { queryFn: async () => {
const fetchItemData = async () => { if (offline) {
setItemStatus({ isLoading: true, isError: false }); const item = await getDownloadedItem(itemId);
try { if (item) return item.item;
let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) fetchedItem = data.item as BaseItemDto;
} else {
const res = await getUserLibraryApi(api!).getItem({
itemId,
userId: user?.Id,
});
fetchedItem = res.data;
}
setItem(fetchedItem);
setItemStatus({ isLoading: false, isError: false });
} catch (error) {
console.error("Failed to fetch item:", error);
setItemStatus({ isLoading: false, isError: true });
} }
};
if (itemId) { const res = await getUserLibraryApi(api!).getItem({
fetchItemData(); itemId,
} userId: user?.Id,
}, [itemId, offline, api, user?.Id]);
interface Stream {
mediaSource: MediaSourceInfo;
sessionId: string;
url: string;
}
const [stream, setStream] = useState<Stream | null>(null);
const [streamStatus, setStreamStatus] = useState({
isLoading: true,
isError: false,
});
useEffect(() => {
const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
try {
let result: Stream | null = null;
if (offline && !Platform.isTV) {
const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) {
result = { mediaSource: data.mediaSource, sessionId: "", url };
}
} else {
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert(
t("player.error"),
t("player.failed_to_get_stream_url"),
);
return;
}
result = { mediaSource, sessionId, url };
}
setStream(result);
setStreamStatus({ isLoading: false, isError: false });
} catch (error) {
console.error("Failed to fetch stream:", error);
setStreamStatus({ isLoading: false, isError: true });
}
};
fetchStreamData();
}, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
useEffect(() => {
if (!stream) return;
const reportPlaybackStart = async () => {
await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
}); });
};
reportPlaybackStart(); return res.data;
}, [stream]); },
enabled: !!itemId,
staleTime: 0,
});
const togglePlay = async () => { const {
lightHapticFeedback(); data: stream,
setIsPlaying(!isPlaying); isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, mediaSourceId, bitrateValue],
queryFn: async () => {
if (offline) {
const data = await getDownloadedItem(itemId);
if (!data?.mediaSource) return null;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item)
return {
mediaSource: data.mediaSource,
url,
sessionId: undefined,
};
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: native,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
Alert.alert("Error", "Failed to get stream url");
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!itemId && !!item,
staleTime: 0,
});
const togglePlay = useCallback(async () => {
if (!api) return;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
reportPlaybackProgress();
if (!offline && stream) {
await getPlaystateApi(api).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: true,
playMethod: stream.url?.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({ if (!offline && stream) {
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, await getPlaystateApi(api).onPlaybackProgress({
}); itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream.sessionId,
});
}
} }
}; }, [
isPlaying,
api,
item,
stream,
videoRef,
audioIndex,
subtitleIndex,
mediaSourceId,
offline,
progress.value,
]);
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(progress.get());
const currentTimeInTicks = msToTicks(progress.value);
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
@@ -244,15 +236,7 @@ export default function page() {
}); });
revalidateProgressCache(); revalidateProgressCache();
}, [ }, [api, item, mediaSourceId, stream]);
api,
item,
mediaSourceId,
stream,
progress,
offline,
revalidateProgressCache,
]);
const stop = useCallback(() => { const stop = useCallback(() => {
reportPlaybackStopped(); reportPlaybackStopped();
@@ -260,256 +244,184 @@ export default function page() {
videoRef.current?.stop(); videoRef.current?.stop();
}, [videoRef, reportPlaybackStopped]); }, [videoRef, reportPlaybackStopped]);
useEffect(() => { // TODO: unused should remove.
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const reportPlaybackStart = useCallback(async () => {
return () => { if (offline) return;
beforeRemoveListener();
};
}, [navigation, stop]);
const currentPlayStateInfo = () => {
if (!stream) return; if (!stream) return;
return { await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id!, itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined, subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
positionTicks: msToTicks(progress.get()), playMethod: stream.url?.includes("m3u8") ? "Transcode" : "DirectStream",
isPaused: !isPlaying, playSessionId: stream?.sessionId ? stream?.sessionId : undefined,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream", });
playSessionId: stream.sessionId, }, [api, item, mediaSourceId, stream]);
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.value === true) return;
if (isPlaybackStopped === true) return;
const { currentTime } = data.nativeEvent; const { currentTime } = data.nativeEvent;
if (isBuffering) { if (isBuffering) {
setIsBuffering(false); setIsBuffering(false);
} }
progress.set(currentTime); progress.value = currentTime;
if (offline) return; if (offline) return;
const currentTimeInTicks = msToTicks(currentTime);
if (!item?.Id || !stream) return; if (!item?.Id || !stream) return;
reportPlaybackProgress(); await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(currentTimeInTicks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream.sessionId,
});
}, },
[ [item?.Id, isPlaying, api, isPlaybackStopped, audioIndex, subtitleIndex]
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
isPlaying,
stream,
isSeeking,
isPlaybackStopped,
isBuffering,
],
); );
const onPipStarted = useCallback((e: PipStartedPayload) => { useOrientation();
const { pipStarted } = e.nativeEvent; useOrientationSettings();
setIsPipStarted(pipStarted);
}, []);
const reportPlaybackProgress = useCallback(async () => {
if (!api || offline || !stream) return;
await getPlaystateApi(api).reportPlaybackProgress({
playbackProgressInfo: currentPlayStateInfo() as PlaybackProgressInfo,
});
}, [
api,
isPlaying,
offline,
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
]);
const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item, offline]);
const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.min(currentVolume + 0.1, 1.0);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []);
const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const toggleMuteCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const currentVolumePercent = currentVolume * 100;
if (currentVolumePercent > 0) {
// Currently not muted, so mute
setPreviousVolume(currentVolumePercent);
await VolumeManager.setVolume(0);
setIsMuted(true);
} else {
// Currently muted, so restore previous volume
const volumeToRestore = previousVolume || 50; // Default to 50% if no previous volume
await VolumeManager.setVolume(volumeToRestore / 100);
setPreviousVolume(null);
setIsMuted(false);
}
} catch (error) {
console.error("Error toggling mute:", error);
}
}, [previousVolume]);
const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return;
try {
const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
console.log(
"Volume Down",
Math.round(currentVolume * 100),
"→",
Math.round(newVolume * 100),
);
await VolumeManager.setVolume(newVolume);
} catch (error) {
console.error("Error adjusting volume:", error);
}
}, []);
const setVolumeCb = useCallback(async (newVolume: number) => {
if (Platform.isTV) return;
try {
const clampedVolume = Math.max(0, Math.min(newVolume, 100));
console.log("Setting volume to", clampedVolume);
await VolumeManager.setVolume(clampedVolume / 100);
} catch (error) {
console.error("Error setting volume:", error);
}
}, []);
useWebSocket({ 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 onPlaybackStateChanged = useCallback((e: PlaybackStatePayload) => {
async (e: PlaybackStatePayload) => { const { state, isBuffering, isPlaying } = e.nativeEvent;
const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") {
setIsPlaying(true);
reportPlaybackProgress();
if (!Platform.isTV) await activateKeepAwakeAsync();
return;
}
if (state === "Paused") { if (state === "Playing") {
setIsPlaying(false); setIsPlaying(true);
reportPlaybackProgress(); return;
if (!Platform.isTV) await deactivateKeepAwake(); }
return;
}
if (isPlaying) { if (state === "Paused") {
setIsPlaying(true); setIsPlaying(false);
setIsBuffering(false); return;
} else if (isBuffering) { }
setIsBuffering(true);
}
},
[reportPlaybackProgress],
);
const allAudio = if (isPlaying) {
stream?.mediaSource.MediaStreams?.filter( setIsPlaying(true);
(audio) => audio.Type === "Audio", setIsBuffering(false);
) || []; } else if (isBuffering) {
setIsBuffering(true);
// Move all the external subtitles last, because vlc places them last. }
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle",
).sort((a, b) => Number(a.IsExternal) - Number(b.IsExternal)) || [];
const externalSubtitles = allSubs
.filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({
name: sub.DisplayTitle,
DeliveryUrl: api?.basePath + sub.DeliveryUrl,
}));
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex,
);
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
const notTranscoding = !stream?.mediaSource.TranscodingUrl;
const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
if (
chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) {
const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack)
: textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`);
}
if (notTranscoding && chosenAudioTrack) {
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
}
const [isMounted, setIsMounted] = useState(false);
// Add useEffect to handle mounting
useEffect(() => {
setIsMounted(true);
return () => setIsMounted(false);
}, []); }, []);
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) { const startPosition = useMemo(() => {
if (offline) return 0;
return item?.UserData?.PlaybackPositionTicks
? ticksToSeconds(item.UserData.PlaybackPositionTicks)
: 0;
}, [item]);
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
const [appState, setAppState] = useState(AppState.currentState);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (appState.match(/inactive|background/) && nextAppState === "active") {
// Handle app coming to the foreground
} else if (nextAppState.match(/inactive|background/)) {
// Handle app going to the background
if (videoRef.current && videoRef.current.pause) {
videoRef.current.pause();
}
}
setAppState(nextAppState);
};
// Use AppState.addEventListener and return a cleanup function
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
return () => {
// Cleanup the event listener when the component is unmounted
subscription.remove();
};
}, [appState]);
// Preselection of audio and subtitle tracks.
if (!settings) return null;
let initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
let externalTrack = { name: "", DeliveryUrl: "" };
const allSubs =
stream?.mediaSource.MediaStreams?.filter(
(sub) => sub.Type === "Subtitle"
) || [];
const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex
);
const allAudio =
stream?.mediaSource.MediaStreams?.filter(
(audio) => audio.Type === "Audio"
) || [];
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
// Direct playback CASE
if (!bitrateValue) {
// If Subtitle is embedded we can use the position to select it straight away.
if (chosenSubtitleTrack && !chosenSubtitleTrack.DeliveryUrl) {
initOptions.push(`--sub-track=${allSubs.indexOf(chosenSubtitleTrack)}`);
} else if (chosenSubtitleTrack && chosenSubtitleTrack.DeliveryUrl) {
// If Subtitle is external we need to pass the URL to the player.
externalTrack = {
name: chosenSubtitleTrack.DisplayTitle || "",
DeliveryUrl: `${api?.basePath || ""}${chosenSubtitleTrack.DeliveryUrl}`,
};
}
if (chosenAudioTrack)
initOptions.push(`--audio-track=${allAudio.indexOf(chosenAudioTrack)}`);
} else {
// Transcoded playback CASE
if (chosenSubtitleTrack?.DeliveryMethod === "Hls") {
externalTrack = {
name: `subs ${chosenSubtitleTrack.DisplayTitle}`,
DeliveryUrl: "",
};
}
}
if (!item || isLoadingItem || isLoadingStreamUrl || !stream)
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">
<Loader /> <Loader />
</View> </View>
); );
}
if (itemStatus.isError || streamStatus.isError) if (isErrorItem || isErrorStreamUrl)
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">Error</Text>
</View> </View>
); );
@@ -523,39 +435,37 @@ export default function page() {
position: "relative", position: "relative",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
paddingLeft: ignoreSafeAreas ? 0 : insets.left,
paddingRight: ignoreSafeAreas ? 0 : insets.right,
}} }}
> >
<VlcPlayerView <VlcPlayerView
ref={videoRef} ref={videoRef}
source={{ source={{
uri: stream?.url || "", uri: stream.url,
autoplay: true, autoplay: true,
isNetwork: true, isNetwork: true,
startPosition, startPosition,
externalSubtitles, externalTrack,
initOptions, initOptions,
}} }}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
onVideoProgress={onProgress} onVideoProgress={onProgress}
progressUpdateInterval={1000} progressUpdateInterval={1000}
onVideoStateChange={onPlaybackStateChanged} onVideoStateChange={onPlaybackStateChanged}
onPipStarted={onPipStarted} onVideoLoadStart={() => {}}
onVideoLoadEnd={() => { onVideoLoadEnd={() => {
setIsVideoLoaded(true); setIsVideoLoaded(true);
}} }}
onVideoError={(e) => { onVideoError={(e) => {
console.error("Video Error:", e.nativeEvent); console.error("Video Error:", e.nativeEvent);
Alert.alert( Alert.alert(
t("player.error"), "Error",
t("player.an_error_occured_while_playing_the_video"), "An error occurred while playing the video. Check logs in settings."
); );
writeToLog("ERROR", "Video Error", e.nativeEvent); writeToLog("ERROR", "Video Error", e.nativeEvent);
}} }}
/> />
</View> </View>
{videoRef.current && !isPipStarted && isMounted === true ? ( {videoRef.current && (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -571,7 +481,6 @@ export default function page() {
setIgnoreSafeAreas={setIgnoreSafeAreas} setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas} ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
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}
@@ -582,9 +491,29 @@ export default function page() {
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
/> />
) : null} )}
</View> </View>
); );
} }
export function usePoster(
item: BaseItemDto,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}

View File

@@ -0,0 +1,417 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useWebSocket } from "@/hooks/useWebsockets";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, { OnProgressData, VideoRef } from "react-native-video";
export default function page() {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const windowDimensions = useWindowDimensions();
const firstTime = useRef(true);
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr
? parseInt(subtitleIndexStr, 10)
: undefined;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: undefined;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) return;
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
enabled: !!itemId && !!api,
staleTime: 0,
});
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url"],
queryFn: async () => {
if (!api) return;
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) return null;
return {
mediaSource,
sessionId,
url,
};
},
});
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(
async (ticks: number) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(ticks),
isPaused: true,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(ticks),
isPaused: false,
playMethod: stream?.url.includes("m3u8")
? "Transcode"
: "DirectStream",
playSessionId: stream?.sessionId,
});
}
},
[
isPlaying,
api,
item,
videoRef,
settings,
audioIndex,
subtitleIndex,
mediaSourceId,
stream,
]
);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const stop = useCallback(() => {
setIsPlaybackStopped(true);
videoRef.current?.pause();
reportPlaybackStopped();
}, [videoRef]);
const seek = useCallback(
(seconds: number) => {
videoRef.current?.seek(seconds);
},
[videoRef]
);
const reportPlaybackStopped = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
playSessionId: stream?.sessionId,
});
};
const reportPlaybackStart = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item?.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = data.currentTime * 10000000;
progress.value = secondsToTicks(data.currentTime);
cacheProgress.value = secondsToTicks(data.playableDuration);
setIsBuffering(data.playableDuration === 0);
if (!item?.Id || data.currentTime === 0) return;
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
},
[
item,
isPlaying,
api,
isPlaybackStopped,
audioIndex,
subtitleIndex,
mediaSourceId,
stream,
]
);
useFocusEffect(
useCallback(() => {
play();
return () => {
stop();
};
}, [play, stop])
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
pauseVideo: pause,
playVideo: play,
stopPlayback: stop,
});
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
if (!item || !stream)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View
style={{
width: windowDimensions.width,
height: windowDimensions.height,
position: "relative",
}}
className="flex flex-col items-center justify-center"
>
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
<Image
source={poster}
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
/>
</View>
<Pressable
onPress={() => {
setShowControls(!showControls);
}}
className="absolute z-0 h-full w-full opacity-0"
>
{videoSource && (
<Video
ref={videoRef}
source={videoSource}
style={{ width: "100%", height: "100%" }}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={() => {}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
setIsPlaying(state.isPlaying);
}}
/>
)}
</Pressable>
<Controls
item={item}
videoRef={videoRef}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
enableTrickplay={false}
pause={pause}
play={play}
seek={seek}
isVlc={false}
stop={stop}
/>
</View>
);
}
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster]);
return videoSource;
}

View File

@@ -0,0 +1,546 @@
import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls";
import { useOrientation } from "@/hooks/useOrientation";
import { useOrientationSettings } from "@/hooks/useOrientationSettings";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets";
import { TrackInfo } from "@/modules/vlc-player";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import transcoding from "@/utils/profiles/transcoding";
import { secondsToTicks } from "@/utils/secondsToTicks";
import { Api } from "@jellyfin/sdk";
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import {
getPlaystateApi,
getUserLibraryApi,
} from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import * as Haptics from "expo-haptics";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { View } from "react-native";
import { useSharedValue } from "react-native-reanimated";
import Video, {
OnProgressData,
SelectedTrack,
SelectedTrackType,
VideoRef,
} from "react-native-video";
import { SubtitleHelper } from "@/utils/SubtitleHelper";
const Player = () => {
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const [settings] = useSettings();
const videoRef = useRef<VideoRef | null>(null);
const firstTime = useRef(true);
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true);
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isBuffering, setIsBuffering] = useState(true);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const setShowControls = useCallback((show: boolean) => {
_setShowControls(show);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
const progress = useSharedValue(0);
const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0);
const {
itemId,
audioIndex: audioIndexStr,
subtitleIndex: subtitleIndexStr,
mediaSourceId,
bitrateValue: bitrateValueStr,
} = useLocalSearchParams<{
itemId: string;
audioIndex: string;
subtitleIndex: string;
mediaSourceId: string;
bitrateValue: string;
}>();
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
const subtitleIndex = subtitleIndexStr
? parseInt(subtitleIndexStr, 10)
: undefined;
const bitrateValue = bitrateValueStr
? parseInt(bitrateValueStr, 10)
: undefined;
const {
data: item,
isLoading: isLoadingItem,
isError: isErrorItem,
} = useQuery({
queryKey: ["item", itemId],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!itemId) {
console.warn("No itemId");
return null;
}
const res = await getUserLibraryApi(api).getItem({
itemId,
userId: user?.Id,
});
return res.data;
},
staleTime: 0,
});
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
const {
data: stream,
isLoading: isLoadingStreamUrl,
isError: isErrorStreamUrl,
} = useQuery({
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
queryFn: async () => {
if (!api) {
throw new Error("No api");
}
if (!item) {
console.warn("No item", itemId, item);
return null;
}
const res = await getStreamUrl({
api,
item,
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
userId: user?.Id,
audioStreamIndex: audioIndex,
maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex,
deviceProfile: transcoding,
});
if (!res) return null;
const { mediaSource, sessionId, url } = res;
if (!sessionId || !mediaSource || !url) {
console.warn("No sessionId or mediaSource or url", url);
return null;
}
return {
mediaSource,
sessionId,
url,
};
},
enabled: !!item,
staleTime: 0,
});
const poster = usePoster(item, api);
const videoSource = useVideoSource(item, api, poster, stream?.url);
const togglePlay = useCallback(async () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (isPlaying) {
videoRef.current?.pause();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: true,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
} else {
videoRef.current?.resume();
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item?.Id!,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
isPaused: false,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
}
}, [
isPlaying,
api,
item,
videoRef,
settings,
stream,
audioIndex,
subtitleIndex,
mediaSourceId,
]);
const play = useCallback(() => {
videoRef.current?.resume();
reportPlaybackStart();
}, [videoRef]);
const pause = useCallback(() => {
videoRef.current?.pause();
}, [videoRef]);
const seek = useCallback(
(seconds: number) => {
videoRef.current?.seek(seconds);
},
[videoRef]
);
const reportPlaybackStopped = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStopped({
itemId: item.Id,
mediaSourceId: mediaSourceId,
positionTicks: Math.floor(progress.value),
playSessionId: stream?.sessionId,
});
revalidateProgressCache();
};
const stop = useCallback(() => {
reportPlaybackStopped();
videoRef.current?.pause();
setIsPlaybackStopped(true);
}, [videoRef, reportPlaybackStopped]);
const reportPlaybackStart = async () => {
if (!item?.Id) return;
await getPlaystateApi(api!).onPlaybackStart({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
};
const onProgress = useCallback(
async (data: OnProgressData) => {
if (isSeeking.value === true) return;
if (isPlaybackStopped === true) return;
const ticks = secondsToTicks(data.currentTime);
progress.value = ticks;
cacheProgress.value = secondsToTicks(data.playableDuration);
// TODO: Use this when streaming with HLS url, but NOT when direct playing
// TODO: since playable duration is always 0 then.
setIsBuffering(data.playableDuration === 0);
if (!item?.Id || data.currentTime === 0) {
return;
}
await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined,
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
mediaSourceId: mediaSourceId,
positionTicks: Math.round(ticks),
isPaused: !isPlaying,
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
playSessionId: stream?.sessionId,
});
},
[
item,
isPlaying,
api,
isPlaybackStopped,
isSeeking,
stream,
mediaSourceId,
audioIndex,
subtitleIndex,
]
);
useOrientation();
useOrientationSettings();
useWebSocket({
isPlaying: isPlaying,
togglePlay: togglePlay,
stopPlayback: stop,
offline: false,
});
const [selectedTextTrack, setSelectedTextTrack] = useState<
SelectedTrack | undefined
>();
const [embededTextTracks, setEmbededTextTracks] = useState<
{
index: number;
language?: string | undefined;
selected?: boolean | undefined;
title?: string | undefined;
type: any;
}[]
>([]);
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
SelectedTrack | undefined
>(undefined);
useEffect(() => {
if (selectedTextTrack === undefined) {
const subtitleHelper = new SubtitleHelper(
stream?.mediaSource.MediaStreams ?? []
);
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
subtitleIndex!
);
// Most likely the subtitle is burned in.
if (embeddedTrackIndex === -1) return;
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: embeddedTrackIndex,
});
}
}, [embededTextTracks]);
const getAudioTracks = (): TrackInfo[] => {
return audioTracks.map((t) => ({
name: t.name,
index: t.index,
}));
};
const getSubtitleTracks = (): TrackInfo[] => {
return embededTextTracks.map((t) => ({
name: t.title ?? "",
index: t.index,
language: t.language,
}));
};
useFocusEffect(
React.useCallback(() => {
return async () => {
stop();
};
}, [])
);
if (isLoadingItem || isLoadingStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Loader />
</View>
);
if (isErrorItem || isErrorStreamUrl)
return (
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
<Text className="text-white">Error</Text>
</View>
);
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
flexDirection: "column",
justifyContent: "center",
}}
>
{videoSource ? (
<>
<Video
ref={videoRef}
source={videoSource}
style={{
height: "100%",
width: "100%",
}}
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
onProgress={onProgress}
onError={(e) => {
console.error("Error playing video", e);
}}
onLoad={() => {
if (firstTime.current === true) {
play();
firstTime.current = false;
}
}}
progressUpdateInterval={500}
playWhenInactive={true}
allowsExternalPlayback={true}
playInBackground={true}
pictureInPicture={true}
showNotificationControls={true}
ignoreSilentSwitch="ignore"
fullscreen={false}
onPlaybackStateChanged={(state) => {
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
}}
onTextTracks={(data) => {
setEmbededTextTracks(data.textTracks as any);
}}
onBuffer={(e) => {
setIsBuffering(e.isBuffering);
}}
onAudioTracks={(e) => {
setAudioTracks(
e.audioTracks.map((t) => ({
index: t.index,
name: t.title ?? "",
language: t.language,
}))
);
}}
selectedTextTrack={selectedTextTrack}
selectedAudioTrack={selectedAudioTrack}
/>
</>
) : (
<Text>No video source...</Text>
)}
</View>
{item && (
<Controls
mediaSource={stream?.mediaSource}
videoRef={videoRef}
enableTrickplay={true}
item={item}
togglePlay={togglePlay}
isPlaying={isPlaying}
isSeeking={isSeeking}
progress={progress}
cacheProgress={cacheProgress}
isBuffering={isBuffering}
showControls={showControls}
setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
seek={seek}
play={play}
pause={pause}
stop={stop}
getSubtitleTracks={getSubtitleTracks}
setSubtitleTrack={(i) => {
if (i === -1) {
setSelectedTextTrack({
type: SelectedTrackType.DISABLED,
value: undefined,
});
return;
}
setSelectedTextTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
getAudioTracks={getAudioTracks}
setAudioTrack={(i) => {
setSelectedAudioTrack({
type: SelectedTrackType.INDEX,
value: i,
});
}}
/>
)}
</View>
);
};
export function usePoster(
item: BaseItemDto | null | undefined,
api: Api | null
): string | undefined {
const poster = useMemo(() => {
if (!item || !api) return undefined;
return item.Type === "Audio"
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
: getBackdropUrl({
api,
item: item,
quality: 70,
width: 200,
});
}, [item, api]);
return poster ?? undefined;
}
export function useVideoSource(
item: BaseItemDto | null | undefined,
api: Api | null,
poster: string | undefined,
url?: string | null
) {
const videoSource = useMemo(() => {
if (!item || !api || !url) {
return null;
}
const startPosition = item?.UserData?.PlaybackPositionTicks
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
: 0;
return {
uri: url,
isNetwork: true,
startPosition,
headers: getAuthHeaders(api),
metadata: {
artist: item?.AlbumArtist ?? undefined,
title: item?.Name || "Unknown",
description: item?.Overview ?? undefined,
imageUri: poster,
subtitle: item?.Album ?? undefined,
},
};
}, [item, api, poster, url]);
return videoSource;
}
export default Player;

View File

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

View File

@@ -1,5 +1,5 @@
import { ScrollViewStyleReset } from "expo-router/html"; import { ScrollViewStyleReset } from "expo-router/html";
import type { PropsWithChildren } from "react"; import { type PropsWithChildren } from "react";
/** /**
* This file is web-only and used to configure the root HTML for every web page during static rendering. * This file is web-only and used to configure the root HTML for every web page during static rendering.
@@ -7,13 +7,13 @@ import type { PropsWithChildren } from "react";
*/ */
export default function Root({ children }: PropsWithChildren) { 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

@@ -1,17 +1,20 @@
import { Link, Stack } from "expo-router"; import { Link, Stack, usePathname } from "expo-router";
import { StyleSheet } from "react-native"; import { StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
import { useEffect } from "react";
export default function NotFoundScreen() { export default function NotFoundScreen() {
const pathname = usePathname();
return ( return (
<> <>
<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,113 +1,80 @@
import "@/augmentations"; import "@/augmentations";
import i18n from "@/i18n"; import { Text } from "@/components/common/Text";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
import { import {
JellyfinProvider,
apiAtom,
getOrSetDeviceId, getOrSetDeviceId,
getTokenFromStorage, getTokenFromStorage,
JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider"; import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings"; import { orientationAtom } from "@/utils/atoms/orientation";
import { import { Settings, useSettings } from "@/utils/atoms/settings";
BACKGROUND_FETCH_TASK, import { BACKGROUND_FETCH_TASK } from "@/utils/background-tasks";
BACKGROUND_FETCH_TASK_SESSIONS, import { LogProvider, writeToLog } from "@/utils/log";
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 { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { ActionSheetProvider } from "@expo/react-native-action-sheet";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { Platform } from "react-native"; import {
const BackGroundDownloader = !Platform.isTV checkForExistingDownloads,
? require("@kesha-antonov/react-native-background-downloader") completeHandler,
: null; download,
} from "@kesha-antonov/react-native-background-downloader";
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 import * as BackgroundFetch from "expo-background-fetch";
? require("expo-background-fetch")
: null;
import * as Device from "expo-device";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
const Notifications = !Platform.isTV ? require("expo-notifications") : null; import { useFonts } from "expo-font";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import { useKeepAwake } from "expo-keep-awake";
import { Stack, router, useSegments } from "expo-router"; import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { router, Stack } from "expo-router";
import * as ScreenOrientation from "expo-screen-orientation";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; import * as TaskManager from "expo-task-manager";
import { getLocales } from "expo-localization"; import { Provider as JotaiProvider, useAtom } from "jotai";
import { Provider as JotaiProvider } from "jotai"; import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react"; import { Appearance, AppState, TouchableOpacity } from "react-native";
import { I18nextProvider } from "react-i18next";
import { AppState, Appearance } 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 { userAtom } from "@/providers/JellyfinProvider";
import { store } from "@/utils/store";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import type { EventSubscription } from "expo-modules-core";
import type {
Notification,
NotificationResponse,
} from "expo-notifications/build/Notifications.types";
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
import { useAtom } from "jotai";
import { Toaster } from "sonner-native"; import { Toaster } from "sonner-native";
if (!Platform.isTV) {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
}
// Keep the splash screen visible while we fetch resources
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
// Set the animation options. This is optional. Notifications.setNotificationHandler({
SplashScreen.setOptions({ handleNotification: async () => ({
duration: 500, shouldShowAlert: true,
fade: true, shouldPlaySound: true,
shouldSetBadge: false,
}),
}); });
function useNotificationObserver() { function useNotificationObserver() {
if (Platform.isTV) return;
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
function redirect(notification: typeof Notifications.Notification) { function redirect(notification: Notifications.Notification) {
const url = notification.request.content.data?.url; const url = notification.request.content.data?.url;
if (url) { if (url) {
router.push(url); router.push(url);
} }
} }
Notifications.getLastNotificationResponseAsync().then( Notifications.getLastNotificationResponseAsync().then((response) => {
(response: { notification: any }) => { if (!isMounted || !response?.notification) {
if (!isMounted || !response?.notification) { return;
return; }
} redirect(response?.notification);
redirect(response?.notification); });
},
);
const subscription = Notifications.addNotificationResponseReceivedListener( const subscription = Notifications.addNotificationResponseReceivedListener(
(response: { notification: any }) => { (response) => {
redirect(response.notification); redirect(response.notification);
}, }
); );
return () => { return () => {
@@ -117,122 +84,104 @@ function useNotificationObserver() {
}, []); }, []);
} }
if (!Platform.isTV) { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => { console.log("TaskManager ~ trigger");
console.log("TaskManager ~ sessions trigger");
const api = store.get(apiAtom); const now = Date.now();
if (api === null || api === undefined) return;
const response = await getSessionApi(api).getSessions({ const settingsData = storage.getString("settings");
activeWithinSeconds: 360,
});
const result = response.data.filter((s) => s.NowPlayingItem); if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
Notifications.setBadgeCountAsync(result.length);
return BackgroundFetch.BackgroundFetchResult.NewData; const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
}); });
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { console.log("TaskManager ~ Active jobs: ", jobs.length);
console.log("TaskManager ~ trigger");
const now = Date.now(); for (let job of jobs) {
if (job.status === "completed") {
const downloadUrl = url + "download/" + job.id;
const tasks = await checkForExistingDownloads();
const settingsData = storage.getString("settings"); if (tasks.find((task) => task.id === job.id)) {
console.log("TaskManager ~ Download already in progress: ", job.id);
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; continue;
const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload || !url)
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory)
return BackgroundFetch.BackgroundFetchResult.NoData;
const jobs = await getAllJobsByDeviceId({
deviceId,
authHeader: token,
url,
});
console.log("TaskManager ~ Active jobs: ", jobs.length);
for (const 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,
});
});
} }
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);
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) => {
console.log("TaskManager ~ Download error: ", job.id, error);
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()}`); 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;
}); });
}
const checkAndRequestPermissions = async () => { const checkAndRequestPermissions = async () => {
try { try {
const hasAskedBefore = storage.getString( const hasAskedBefore = storage.getString(
"hasAskedForNotificationPermission", "hasAskedForNotificationPermission"
); );
if (hasAskedBefore !== "true") { if (hasAskedBefore !== "true") {
@@ -254,25 +203,33 @@ 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);
} }
}; };
export default function RootLayout() { export default function RootLayout() {
const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
Appearance.setColorScheme("dark"); Appearance.setColorScheme("dark");
if (!loaded) {
return null;
}
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <JotaiProvider>
<JotaiProvider> <Layout />
<ActionSheetProvider> </JotaiProvider>
<I18nextProvider i18n={i18n}>
<Layout />
</I18nextProvider>
</ActionSheetProvider>
</JotaiProvider>
</GestureHandlerRootView>
); );
} }
@@ -289,236 +246,146 @@ const queryClient = new QueryClient({
}); });
function Layout() { function Layout() {
const [settings] = useSettings(); const [settings, updateSettings] = useSettings();
const [user] = useAtom(userAtom); const [orientation, setOrientation] = useAtom(orientationAtom);
const [api] = useAtom(apiAtom);
const appState = useRef(AppState.currentState); useKeepAwake();
const segments = useSegments(); useNotificationObserver();
useEffect(() => { useEffect(() => {
i18n.changeLanguage( checkAndRequestPermissions();
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en", }, []);
);
}, [settings?.preferedLanguage, i18n]);
if (!Platform.isTV) { useEffect(() => {
useNotificationObserver(); if (settings?.autoRotate === true)
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.DEFAULT);
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>(); else
const notificationListener = useRef<EventSubscription>(); ScreenOrientation.lockAsync(
const responseListener = useRef<EventSubscription>(); ScreenOrientation.OrientationLock.PORTRAIT_UP
useEffect(() => {
if (expoPushToken && api && user) {
api
?.post("/Streamyfin/device", {
token: expoPushToken.data,
deviceId: getOrSetDeviceId(),
userId: user.Id,
})
.then((_) => console.log("Posted expo push token"))
.catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"),
);
} else console.log("No token available");
}, [api, expoPushToken, user]);
async function registerNotifications() {
if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", {
name: "default",
});
}
await checkAndRequestPermissions();
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
// only create push token for real devices (pointless for emulators)
if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
}
useEffect(() => {
registerNotifications();
notificationListener.current =
Notifications?.addNotificationReceivedListener(
(notification: Notification) => {
console.log(
"Notification received while app running",
notification,
);
},
);
responseListener.current =
Notifications?.addNotificationResponseReceivedListener(
(response: NotificationResponse) => {
// Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content;
writeDebugLog(
`Notification ${title} opened`,
response.notification.request.content,
);
if (data && Object.keys(data).length > 0) {
const type = data?.type?.toLower?.();
const itemId = data?.id;
switch (type) {
case "movie":
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
break;
case "episode":
// We just clicked a notification for an individual episode.
if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
}
// summarized season notification for multiple episodes. Bring them to series season
else {
const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex;
if (seasonIndex) {
router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
);
} else {
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
}
}
break;
}
}
},
);
return () => {
notificationListener.current &&
Notifications?.removeNotificationSubscription(
notificationListener.current,
);
responseListener.current &&
Notifications?.removeNotificationSubscription(
responseListener.current,
);
};
}, []);
useEffect(() => {
if (Platform.isTV) return;
if (segments.includes("direct-player" as never)) {
return;
}
// If the user has auto rotate enabled, unlock the orientation
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
// If the user has auto rotate disabled, lock the orientation to portrait
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [settings.followDeviceOrientation, segments]);
useEffect(() => {
const subscription = AppState.addEventListener(
"change",
(nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
},
); );
}, [settings]);
BackGroundDownloader.checkForExistingDownloads(); const appState = useRef(AppState.currentState);
return () => { useEffect(() => {
subscription.remove(); const subscription = AppState.addEventListener("change", (nextAppState) => {
}; if (
}, []); appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
checkForExistingDownloads();
}
});
checkForExistingDownloads();
return () => {
subscription.remove();
};
}, []);
useEffect(() => {
const subscription = ScreenOrientation.addOrientationChangeListener(
(event) => {
setOrientation(event.orientationInfo.orientation);
}
);
ScreenOrientation.getOrientationAsync().then((initialOrientation) => {
setOrientation(initialOrientation);
});
return () => {
ScreenOrientation.removeOrientationChangeListener(subscription);
};
}, []);
const url = Linking.useURL();
if (url) {
const { hostname, path, queryParams } = Linking.parse(url);
} }
return ( return (
<QueryClientProvider client={queryClient}> <GestureHandlerRootView style={{ flex: 1 }}>
<JobQueueProvider> <QueryClientProvider client={queryClient}>
<JellyfinProvider> <ActionSheetProvider>
<PlaySettingsProvider> <JobQueueProvider>
<LogProvider> <JellyfinProvider>
<WebSocketProvider> <PlaySettingsProvider>
<DownloadProvider> <LogProvider>
<BottomSheetModalProvider> <WebSocketProvider>
<SystemBars style='light' hidden={false} /> <DownloadProvider>
<ThemeProvider value={DarkTheme}> <BottomSheetModalProvider>
<Stack initialRouteName='(auth)/(tabs)'> <SystemBars style="light" hidden={false} />
<Stack.Screen <ThemeProvider value={DarkTheme}>
name='(auth)/(tabs)' <Stack initialRouteName="/home">
options={{ <Stack.Screen
headerShown: false, name="(auth)/(tabs)"
title: "", options={{
header: () => null, headerShown: false,
}} title: "",
/> header: () => null,
<Stack.Screen }}
name='(auth)/player' />
options={{ <Stack.Screen
headerShown: false, name="(auth)/player"
title: "", options={{
header: () => null, headerShown: false,
}} title: "",
/> header: () => null,
<Stack.Screen }}
name='login' />
options={{ <Stack.Screen
headerShown: true, name="(auth)/trailer/page"
title: "", options={{
headerTransparent: true, headerShown: false,
}} presentation: "modal",
/> title: "",
<Stack.Screen name='+not-found' /> }}
</Stack> />
<Toaster <Stack.Screen
duration={4000} name="login"
toastOptions={{ options={{
style: { headerShown: true,
backgroundColor: "#262626", title: "",
borderColor: "#363639", headerTransparent: true,
borderWidth: 1, }}
}, />
titleStyle: { <Stack.Screen name="+not-found" />
color: "white", </Stack>
}, <Toaster
}} duration={4000}
closeButton toastOptions={{
/> style: {
</ThemeProvider> backgroundColor: "#262626",
</BottomSheetModalProvider> borderColor: "#363639",
</DownloadProvider> borderWidth: 1,
</WebSocketProvider> },
</LogProvider> titleStyle: {
</PlaySettingsProvider> color: "white",
</JellyfinProvider> },
</JobQueueProvider> }}
</QueryClientProvider> closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</JobQueueProvider>
</ActionSheetProvider>
</QueryClientProvider>
</GestureHandlerRootView>
); );
} }
function saveDownloadedItemInfo(item: BaseItemDto) { function saveDownloadedItemInfo(item: BaseItemDto) {
try { try {
const downloadedItems = storage.getString("downloadedItems"); const downloadedItems = storage.getString("downloadedItems");
const items: BaseItemDto[] = downloadedItems let items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems) ? JSON.parse(downloadedItems)
: []; : [];

View File

@@ -1,17 +1,15 @@
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
import { PreviousServersList } from "@/components/PreviousServersList";
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 { Colors } from "@/constants/Colors"; import { PreviousServersList } from "@/components/PreviousServersList";
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider"; import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client"; import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api";
import { Image } from "expo-image"; import { Image } from "expo-image";
import { useLocalSearchParams, useNavigation } from "expo-router"; import { useLocalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { import {
Alert, Alert,
KeyboardAvoidingView, KeyboardAvoidingView,
@@ -20,20 +18,18 @@ import {
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
import { Keyboard } from "react-native";
import { t } from "i18next";
import { z } from "zod"; import { z } from "zod";
const CredentialsSchema = z.object({ const CredentialsSchema = z.object({
username: z.string().min(1, t("login.username_required")), username: z.string().min(1, "Username is 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,10 +37,9 @@ 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 [error, setError] = useState<string>("");
const [credentials, setCredentials] = useState<{ const [credentials, setCredentials] = useState<{
username: string; username: string;
password: string; password: string;
@@ -53,13 +48,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 +67,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 +77,16 @@ const Login: React.FC = () => {
onPress={() => { onPress={() => {
removeServer(); removeServer();
}} }}
className='flex flex-row items-center'
> >
<Ionicons name='chevron-back' size={18} color={Colors.primary} /> <Ionicons name="chevron-back" size={24} color="white" />
<Text className='ml-2 text-purple-600'>
{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);
@@ -104,18 +95,17 @@ const Login: React.FC = () => {
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Alert.alert(t("login.connection_failed"), error.message); setError(error.message);
} else { } else {
Alert.alert( setError("An unexpected error occurred");
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.
* *
@@ -146,8 +136,6 @@ const Login: React.FC = () => {
return url; return url;
} }
return undefined;
} catch {
return undefined; return undefined;
} finally { } finally {
setLoadingServerCheck(false); setLoadingServerCheck(false);
@@ -171,39 +159,33 @@ const Login: React.FC = () => {
* *
*/ */
const handleConnect = useCallback(async (url: string) => { const handleConnect = useCallback(async (url: string) => {
url = url.trim().replace(/\/$/, ""); url = url.trim();
const result = await checkUrl(url); const result = await checkUrl(url);
if (result === undefined) { if (result === undefined) {
Alert.alert( Alert.alert(
t("login.connection_failed"), "Connection failed",
t("login.could_not_connect_to_server"), "Could not connect to the server. Please check the URL and your network connection."
); );
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("Quick Connect", `Enter code ${code} to login`, [
t("login.quick_connect"), {
t("login.enter_code_to_login", { code: code }), text: "Got It",
[ },
{ ]);
text: t("login.got_it"),
},
],
);
} }
} catch (error) { } catch (error) {
Alert.alert( Alert.alert("Error", "Failed to initiate Quick Connect");
t("login.error_title"),
t("login.failed_to_initiate_quick_connect"),
);
} }
}; };
@@ -214,81 +196,77 @@ const Login: React.FC = () => {
> >
{api?.basePath ? ( {api?.basePath ? (
<> <>
<View className='flex flex-col h-full relative items-center justify-center'> <View className="flex flex-col h-full relative items-center justify-center">
<View className='px-4 -mt-20 w-full'> <View className="px-4 -mt-20 w-full">
<View className='flex flex-col space-y-2'> <View className="flex flex-col space-y-2">
<Text className='text-2xl font-bold -mb-2'> <Text className="text-2xl font-bold -mb-2">
{serverName ? ( Log in
<> <>
{`${t("login.login_to_title")} `} {serverName ? (
<Text className='text-purple-600'>{serverName}</Text> <>
</> {" to "}
) : ( <Text className="text-purple-600">{serverName}</Text>
t("login.login_title") </>
)} ) : null}
</>
</Text> </Text>
<Text className='text-xs text-neutral-400'> <Text className="text-xs text-neutral-400">
{api.basePath} {api.basePath}
</Text> </Text>
<Input <Input
placeholder={t("login.username_placeholder")} placeholder="Username"
onChangeText={(text) => onChangeText={(text) =>
setCredentials({ ...credentials, username: text }) setCredentials({ ...credentials, username: text })
} }
value={credentials.username} value={credentials.username}
keyboardType='default' autoFocus
returnKeyType='done' secureTextEntry={false}
autoCapitalize='none' keyboardType="default"
// Changed from username to oneTimeCode because it is a known issue in RN returnKeyType="done"
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037 autoCapitalize="none"
textContentType='oneTimeCode' textContentType="username"
clearButtonMode='while-editing' clearButtonMode="while-editing"
maxLength={500} maxLength={500}
/> />
<Input <Input
placeholder={t("login.password_placeholder")} className="mb-2"
placeholder="Password"
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'>
<Button
onPress={handleLogin}
loading={loading}
className='flex-1 mr-2'
>
{t("login.login_button")}
</Button>
<TouchableOpacity
onPress={handleQuickConnect}
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
>
<MaterialCommunityIcons
name='cellphone-lock'
size={24}
color='white'
/>
</TouchableOpacity>
</View>
</View> </View>
<Text className="text-red-600 mb-2">{error}</Text>
</View> </View>
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' /> <View className="absolute bottom-0 left-0 w-full px-4 mb-2">
<Button
color="black"
onPress={handleQuickConnect}
className="w-full mb-2"
>
Use Quick Connect
</Button>
<Button onPress={handleLogin} loading={loading}>
Log in
</Button>
</View>
</View> </View>
</> </>
) : ( ) : (
<> <>
<View className='flex flex-col h-full items-center justify-center w-full'> <View className="flex flex-col h-full relative items-center justify-center w-full">
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'> <View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
<Image <Image
style={{ style={{
width: 100, width: 100,
@@ -298,45 +276,38 @@ const Login: React.FC = () => {
}} }}
source={require("@/assets/images/StreamyFinFinal.png")} source={require("@/assets/images/StreamyFinFinal.png")}
/> />
<Text className='text-3xl font-bold'>Streamyfin</Text> <Text className="text-3xl font-bold">Streamyfin</Text>
<Text className='text-neutral-500'> <Text className="text-neutral-500">
{t("server.enter_url_to_jellyfin_server")} Enter the URL to your Jellyfin server
</Text> </Text>
<Input <Input
aria-label='Server URL' placeholder="Server URL"
placeholder={t("server.server_url_placeholder")}
onChangeText={setServerURL} onChangeText={setServerURL}
value={serverURL} value={serverURL}
keyboardType='url' keyboardType="url"
returnKeyType='done' returnKeyType="done"
autoCapitalize='none' autoCapitalize="none"
textContentType='URL' textContentType="URL"
maxLength={500} maxLength={500}
/> />
<Text className="text-xs text-neutral-500 ml-4">
Make sure to include http or https
</Text>
<PreviousServersList
onServerSelect={(s) => {
handleConnect(s.address);
}}
/>
</View>
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
<Button <Button
loading={loadingServerCheck} loading={loadingServerCheck}
disabled={loadingServerCheck} disabled={loadingServerCheck}
onPress={async () => { onPress={async () => await handleConnect(serverURL)}
await handleConnect(serverURL); className="w-full grow"
}}
className='w-full grow'
> >
{t("server.connect_button")} 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>
</> </>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

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

View File

@@ -1,21 +1,17 @@
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
} }
} }
MMKV.prototype.get = function <T>(key: string): T | undefined { MMKV.prototype.get = function <T> (key: string): T | undefined {
const serializedItem = this.getString(key); const serializedItem = this.getString(key);
return serializedItem ? JSON.parse(serializedItem) : undefined; return serializedItem ? JSON.parse(serializedItem) : undefined;
}; }
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
if (value === undefined) { this.set(key, JSON.stringify(value));
this.delete(key); }
} else {
this.set(key, JSON.stringify(value));
}
};

View File

@@ -1,23 +1,25 @@
declare global { declare global {
interface Number { interface Number {
bytesToReadable(decimals?: number): string; bytesToReadable(): string;
secondsToMilliseconds(): number; secondsToMilliseconds(): number;
minutesToMilliseconds(): number; minutesToMilliseconds(): number;
hoursToMilliseconds(): number; hoursToMilliseconds(): number;
} }
} }
Number.prototype.bytesToReadable = function (decimals = 2) { Number.prototype.bytesToReadable = function () {
const bytes = this.valueOf(); const bytes = this.valueOf();
if (bytes === 0) return "0 Bytes"; const gb = bytes / 1e9;
const k = 1024; if (gb >= 1) return `${gb.toFixed(0)} GB`;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const mb = bytes / 1024.0 / 1024.0;
if (mb >= 1) return `${mb.toFixed(0)} MB`;
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; const kb = bytes / 1024.0;
if (kb >= 1) return `${kb.toFixed(0)} KB`;
return `${bytes.toFixed(2)} B`;
}; };
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/1.9.4/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignore": [
"node_modules",
"ios",
"android",
"Streamyfin.app",
"utils/jellyseerr",
".expo"
]
},
"linter": {
"enabled": true,
"rules": {
"style": {
"useImportType": "off",
"noNonNullAssertion": "off",
"noParameterAssign": "off",
"useLiteralEnumMembers": "off"
},
"complexity": {
"noForEach": "off"
},
"recommended": true,
"correctness": { "useExhaustiveDependencies": "off" },
"suspicious": {
"noExplicitAny": "off",
"noArrayIndexKey": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": true,
"attributePosition": "auto",
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 80
},
"javascript": {
"formatter": {
"arrowParentheses": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"jsxQuoteStyle": "single",
"quoteProperties": "asNeeded",
"semicolons": "always",
"lineWidth": 80
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
}
}

2982
bun.lock

File diff suppressed because it is too large Load Diff

BIN
bun.lockb Executable file

Binary file not shown.

View File

@@ -1,23 +1,113 @@
import { RoundButton } from "@/components/RoundButton"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useFavorite } from "@/hooks/useFavorite"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client"; import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import type { FC } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { View, type ViewProps } from "react-native"; 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,8 +1,7 @@
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 { TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; import * as DropdownMenu from "zeego/dropdown-menu";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
interface Props extends React.ComponentProps<typeof View> { interface Props extends React.ComponentProps<typeof View> {
@@ -17,34 +16,29 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
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();
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">Audio</Text>
{t("item_card.audio")} <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> <Text className="" numberOfLines={1}>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text className='' numberOfLines={1}>
{selectedAudioSteam?.DisplayTitle} {selectedAudioSteam?.DisplayTitle}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -52,8 +46,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,8 +1,7 @@
import { Platform, TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; import * as DropdownMenu from "zeego/dropdown-menu";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
import { useMemo } from "react";
export type Bitrate = { export type Bitrate = {
key: string; key: string;
@@ -28,10 +27,6 @@ export const BITRATES: Bitrate[] = [
key: "2 Mb/s", key: "2 Mb/s",
value: 2000000, value: 2000000,
}, },
{
key: "1 Mb/s",
value: 1000000,
},
{ {
key: "500 Kb/s", key: "500 Kb/s",
value: 500000, value: 500000,
@@ -40,11 +35,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;
@@ -58,26 +49,19 @@ export const BitrateSelector: React.FC<Props> = ({
inverted, inverted,
...props ...props
}) => { }) => {
if (Platform.isTV) return null;
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
return BITRATES.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.sort( return BITRATES.sort(
(a, b) => (a, b) => (b.value || Infinity) - (a.value || Infinity)
(b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY),
); );
}, []); }, []);
const { t } = useTranslation();
return ( return (
<View <View
className='flex shrink' className="flex shrink"
style={{ style={{
minWidth: 60, minWidth: 60,
maxWidth: 200, maxWidth: 200,
@@ -85,12 +69,10 @@ 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">Quality</Text>
{t("item_card.quality")} <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> <Text style={{}} className="" numberOfLines={1}>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{BITRATES.find((b) => b.value === selected?.value)?.key} {BITRATES.find((b) => b.value === selected?.value)?.key}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -98,8 +80,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 { useHaptic } from "@/hooks/useHaptic"; import * as Haptics from "expo-haptics";
import type React from "react"; import React, { PropsWithChildren, ReactNode, useMemo } from "react";
import { type PropsWithChildren, type ReactNode, useMemo } from "react"; import { Text, TouchableOpacity, View } from "react-native";
import { Platform, Text, TouchableOpacity, View } from "react-native";
import { Loader } from "./Loader"; import { Loader } from "./Loader";
export interface ButtonProps export interface ButtonProps
@@ -38,14 +37,12 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
case "red": case "red":
return "bg-red-600"; return "bg-red-600";
case "black": case "black":
return "bg-neutral-900"; return "bg-neutral-900 border border-neutral-800";
case "transparent": case "transparent":
return "bg-transparent"; return "bg-transparent";
} }
}, [color]); }, [color]);
const lightHapticFeedback = useHaptic("light");
return ( return (
<TouchableOpacity <TouchableOpacity
className={` className={`
@@ -57,23 +54,21 @@ export const Button: React.FC<PropsWithChildren<ButtonProps>> = ({
onPress={() => { onPress={() => {
if (!loading && !disabled && onPress) { if (!loading && !disabled && onPress) {
onPress(); onPress();
lightHapticFeedback(); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} }
}} }}
disabled={disabled || loading} disabled={disabled || loading}
{...props} {...props}
> >
{loading ? ( {loading ? (
<View className='p-0.5'> <Loader />
<Loader />
</View>
) : ( ) : (
<View <View
className={` className={`
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 +80,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,7 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { BlurView } from "expo-blur";
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { Platform, TouchableOpacity, type ViewProps } from "react-native"; import { Platform, TouchableOpacity, ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
@@ -17,12 +18,12 @@ interface Props extends ViewProps {
background?: "blur" | "transparent"; background?: "blur" | "transparent";
} }
export function Chromecast({ export const Chromecast: React.FC<Props> = ({
width = 48, width = 48,
height = 48, height = 48,
background = "transparent", background = "transparent",
...props ...props
}) { }) => {
const client = useRemoteMediaClient(); const client = useRemoteMediaClient();
const castDevice = useCastDevice(); const castDevice = useCastDevice();
const devices = useDevices(); const devices = useDevices();
@@ -45,18 +46,18 @@ export function Chromecast({
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>
Platform.OS === "android" ? ( Platform.OS === "android" ? (
<CastButton tintColor='transparent' /> <CastButton tintColor="transparent" />
) : ( ) : (
<></> <></>
), ),
[Platform.OS], [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();
@@ -65,13 +66,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();
@@ -79,7 +80,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

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

View File

@@ -1,12 +1,12 @@
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { Ionicons } from "@expo/vector-icons"; import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type { 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 { useMemo } from "react"; import { useMemo } from "react";
import type React from "react";
import { View } from "react-native"; import { View } from "react-native";
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;
@@ -27,39 +27,28 @@ 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`;
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
} }
if (item.ImageTags?.Thumb) {
return `${api?.basePath}/Items/${item.Id}/Images/Thumb?fillHeight=389&quality=80&tag=${item.ImageTags?.Thumb}`;
}
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]); }, [item]);
const progress = useMemo(() => { const progress = useMemo(() => {
@@ -70,12 +59,15 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
const total = endDate.getTime() - startDate.getTime(); const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime(); const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100; return (elapsed / total) * 100;
} else {
return item.UserData?.PlayedPercentage || 0;
} }
return item.UserData?.PlayedPercentage || 0;
}, [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
@@ -84,7 +76,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}
@@ -92,12 +84,12 @@ 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>
@@ -105,16 +97,14 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
{progress > 0 && ( {progress > 0 && (
<> <>
<View <View
className={ className={`absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full`}
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full" ></View>
}
/>
<View <View
style={{ style={{
width: `${progress}%`, width: `${progress}%`,
}} }}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"} className={`absolute bottom-0 left-0 h-1 bg-purple-600 w-full`}
/> ></View>
</> </>
)} )}
</View> </View>

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