Compare commits

..

3 Commits

Author SHA1 Message Date
Fredrik Burmester
83a264d5a1 chore 2025-07-16 19:51:17 +02:00
Fredrik Burmester
2d434a0125 wip 2025-07-15 11:23:38 +02:00
Fredrik Burmester
0d7edca1ad wip 2025-07-15 11:23:35 +02:00
219 changed files with 13163 additions and 8907 deletions

View File

@@ -1,13 +1,21 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(rm:*)",
"Bash(find:*)", "Bash(find:*)",
"Bash(grep:*)",
"Bash(for file in /Users/fredrikburmester/Documents/GitHub/streamyfin/translations/*.json)",
"Bash(do)",
"Bash(if grep -q \"live_tv\" \"$file\")",
"Bash(then)",
"Bash(echo \"Processing $file\")",
"Bash(fi)",
"Bash(done)",
"Bash(bun run:*)",
"Bash(pod install:*)",
"Bash(bun install:*)", "Bash(bun install:*)",
"Bash(bunx expo prebuild:*)", "Bash(ls:*)",
"Bash(bunx expo run:*)", "Bash(cat:*)"
"Bash(npx expo prebuild:*)",
"Bash(npx expo run:*)",
"Bash(xcodebuild:*)"
], ],
"deny": [] "deny": []
} }

View File

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

3
.eslintrc.json Normal file
View File

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

View File

@@ -1,4 +1,4 @@
name: 🤖 Android APK Build (Phone + TV) name: 🤖 Android APK Build
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,82 +12,58 @@ on:
branches: [develop, master] branches: [develop, master]
jobs: jobs:
build-android: build:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
name: 🏗️ Build Android APK name: 🏗️ Build Android APK
permissions: permissions:
contents: read contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps: steps:
- name: 📥 Checkout code - name: 📥 Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
bun-version: latest bun-version: '1.2.17'
- name: ☕ Setup JDK
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: 'zulu'
java-version: '17'
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-${{ runner.arch }}-bun-develop ${{ runner.os }}-bun-cache-
${{ runner.os }}-bun-develop
- name: 💾 Cache node_modules - name: 📦 Install dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: node_modules
key: ${{ runner.os }}-${{ runner.arch }}-modules-latest-develop-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-modules-latest-develop
${{ runner.os }}-${{ runner.arch }}-modules-develop
${{ runner.os }}-modules-develop
- name: 📦 Install dependencies and reload submodules
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload bun run submodule-reload
- name: 💾 Cache Gradle global - name: 💾 Cache Android dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with: with:
path: | path: |
~/.gradle/caches android/.gradle
~/.gradle/wrapper key: ${{ runner.os }}-android-deps-${{ hashFiles('android/**/build.gradle') }}
key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }} restore-keys: |
restore-keys: ${{ runner.os }}-gradle-develop ${{ runner.os }}-android-deps-
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: | run: bun run prebuild
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 💾 Cache project Gradle (.gradle) - name: 🚀 Build APK via Bun
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: android/.gradle
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-android-gradle-develop
- name: 🚀 Build APK
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: bun run build:android:local run: bun run build:android:local
- name: 📅 Set date tag - name: 📅 Set date tag
@@ -96,7 +72,8 @@ jobs:
- name: 📤 Upload APK artifact - name: 📤 Upload APK artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }} name: streamyfin-apk-${{ env.DATE_TAG }}
path: | path: |
android/app/build/outputs/apk/release/*.apk android/app/build/outputs/apk/release/*.apk
android/app/build/outputs/bundle/release/*.aab
retention-days: 7 retention-days: 7

View File

@@ -1,4 +1,4 @@
name: 🤖 iOS IPA Build (Phone + TV) name: 🤖 iOS IPA Build
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -12,71 +12,51 @@ on:
branches: [develop, master] branches: [develop, master]
jobs: jobs:
build-ios: build:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
runs-on: macos-15 runs-on: macos-15
name: 🏗️ Build iOS IPA name: 🏗️ Build iOS IPA
permissions: permissions:
contents: read contents: read
strategy:
fail-fast: false
matrix:
target: [phone, tv]
steps: steps:
- name: 📥 Checkout code - name: 📥 Check out repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: recursive
show-progress: false show-progress: false
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
bun-version: latest bun-version: '1.2.17'
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with: with:
path: ~/.bun/install/cache path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun-cache ${{ runner.os }}-bun-cache-
- name: 📦 Install dependencies and reload submodules - name: 📦 Install & Prepare
run: | run: |
bun install --frozen-lockfile bun install --frozen-lockfile
bun run submodule-reload bun run submodule-reload
- name: 🛠️ Generate project files - name: 🛠️ Generate project files
run: | run: bun run prebuild
if [ "${{ matrix.target }}" = "tv" ]; then
bun run prebuild:tv
else
bun run prebuild
fi
- name: 🏗 Setup EAS - name: 🏗 Setup EAS
uses: expo/expo-github-action@main uses: expo/expo-github-action@main
with: with:
eas-version: latest eas-version: 16.7.1
token: ${{ secrets.EXPO_TOKEN }} token: ${{ secrets.EXPO_TOKEN }}
- name: Ensure iOS/tvOS SDKs installed - name: 🏗 Build iOS app
run: | run: |
if [ "${{ matrix.target }}" = "tv" ]; then eas build -p ios --local --non-interactive
xcodebuild -downloadPlatform tvOS
else
xcodebuild -downloadPlatform iOS
fi
- name: 🚀 Build iOS app
env:
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
run: eas build -p ios --local --non-interactive
- name: 📅 Set date tag - name: 📅 Set date tag
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
@@ -84,6 +64,7 @@ jobs:
- name: 📤 Upload IPA artifact - name: 📤 Upload IPA artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }} name: streamyfin-ipa-${{ env.DATE_TAG }}
path: build-*.ipa path: |
build-*.ipa
retention-days: 7 retention-days: 7

View File

@@ -19,7 +19,7 @@ jobs:
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false show-progress: false
@@ -29,10 +29,10 @@ jobs:
- name: 🍞 Setup Bun - name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
bun-version: latest bun-version: '1.2.17'
- name: 💾 Cache Bun dependencies - name: 💾 Cache Bun dependencies
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with: with:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache

View File

@@ -20,24 +20,24 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: [ 'javascript-typescript', 'actions' ] language: [ 'javascript-typescript' ]
steps: steps:
- name: 📥 Checkout repository - name: 📥 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} ref: ${{ github.event.pull_request.head.sha || github.sha }}
show-progress: false show-progress: false
fetch-depth: 0 fetch-depth: 0
- name: 🏁 Initialize CodeQL - name: 🏁 Initialize CodeQL
uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10 uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality queries: +security-extended,security-and-quality
- name: 🛠️ Autobuild - name: 🛠️ Autobuild
uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10 uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
- name: 🧪 Perform CodeQL Analysis - name: 🧪 Perform CodeQL Analysis
uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10 uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2

View File

@@ -1,12 +1,10 @@
name: 🚦 Security & Quality Gate name: 🚦 Security & Quality Gate
on: on:
pull_request: pull_request_target:
types: [opened, edited, synchronize, reopened] types: [opened, edited, synchronize, reopened]
branches: [develop, master] branches: [develop, master]
workflow_dispatch: workflow_dispatch:
push:
branches: [develop]
permissions: permissions:
contents: read contents: read
@@ -14,18 +12,17 @@ permissions:
jobs: jobs:
validate_pr_title: validate_pr_title:
name: "📝 Validate PR Title" name: "📝 Validate PR Title"
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
pull-requests: write pull-requests: write
contents: read contents: read
steps: steps:
- uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0 - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5.5.3
id: lint_pr_title id: lint_pr_title
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 - uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
if: always() && (steps.lint_pr_title.outputs.error_message != null) if: always() && (steps.lint_pr_title.outputs.error_message != null)
with: with:
header: pr-title-lint-error header: pr-title-lint-error
@@ -39,7 +36,7 @@ jobs:
``` ```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4 uses: marocchino/sticky-pull-request-comment@d2ad0de260ae8b0235ce059e63f2949ba9e05943 # v2.9.3
with: with:
header: pr-title-lint-error header: pr-title-lint-error
delete: true delete: true
@@ -51,41 +48,19 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0 fetch-depth: 0
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2 uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
with: with:
fail-on-severity: high fail-on-severity: high
deny-licenses: GPL-3.0, AGPL-3.0 deny-licenses: GPL-3.0, AGPL-3.0
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }} base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }} head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
expo-doctor:
name: 🚑 Expo Doctor Check
runs-on: ubuntu-24.04
steps:
- name: 🛒 Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive
fetch-depth: 0
- name: 🍞 Setup Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with:
bun-version: latest
- name: 📦 Install dependencies (bun)
run: bun install --frozen-lockfile
- name: 🚑 Run Expo Doctor
run: bun expo-doctor
code_quality: code_quality:
name: "🔍 Lint & Test (${{ matrix.command }})" name: "🔍 Lint & Test (${{ matrix.command }})"
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -95,10 +70,9 @@ jobs:
command: command:
- "lint" - "lint"
- "check" - "check"
- "format"
steps: steps:
- name: "📥 Checkout PR code" - name: "📥 Checkout PR code"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
submodules: recursive submodules: recursive
@@ -107,12 +81,12 @@ jobs:
- name: "🟢 Setup Node.js" - name: "🟢 Setup Node.js"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with: with:
node-version: '24.x' node-version: '22.x'
- name: "🍞 Setup Bun" - name: "🍞 Setup Bun"
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
with: with:
bun-version: latest bun-version: '1.2.17'
- name: "📦 Install dependencies" - name: "📦 Install dependencies"
run: bun install --frozen-lockfile run: bun install --frozen-lockfile

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: 🛎️ Notify Discord - name: 🛎️ Notify Discord
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0 uses: Ilshidur/action-discord@0c4b27844ba47cb1c7bee539c8eead5284ce9fa9 # 0.3.2
env: env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640 DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640

2
.gitignore vendored
View File

@@ -10,6 +10,7 @@ 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
@@ -45,4 +46,3 @@ streamyfin-4fec1-firebase-adminsdk.json
.env .env
.env.local .env.local
*.aab *.aab
/version-backup-*

View File

@@ -1,24 +1,15 @@
# 📺 Streamyfin
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a> <a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
A simple and user-friendly Jellyfin video streaming client built with Expo. If you are looking for an alternative to other Jellyfin clients, we hope you find Streamyfin a useful addition to your media streaming toolbox.
<p align="center"> <div style="display: flex; flex-direction: row; gap: 8px">
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%"> <img width=150 src="./assets/images/screenshots/screenshot1.png" />
</p> <img width=150 src="./assets/images/screenshots/screenshot3.png" />
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.** <img width=159 src="./assets/images/jellyseerr.PNG"/>
</div>
---
<p align="center">
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
&nbsp;
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
&nbsp;
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
&nbsp;
<img src="./assets/images/jellyseerr.PNG" width="23%">
</p>
## 🌟 Features ## 🌟 Features
@@ -56,7 +47,7 @@ The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that ho
### 🔍 Jellysearch ### 🔍 Jellysearch
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! [Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients. > A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
@@ -116,13 +107,13 @@ Key points of the MPL-2.0:
- You must disclose your source code for any modifications to the covered files - You must disclose your source code for any modifications to the covered files
- Larger works may combine MPL code with code under other licenses - Larger works may combine MPL code with code under other licenses
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license - MPL-licensed components must remain under the MPL, but the larger work can be under a different license
- For the full text of the license, please see the LICENSE file in this repository - For the full text of the license, please see the LICENSE file in this repository.
## 🌐 Connect with Us ## 🌐 Connect with Us
Join our Discord: [![](https://dcbadge.limes.pink/api/server/https://discord.gg/BuGG9ZNhaE)](https://discord.gg/BuGG9ZNhaE) Join our Discord: [https://discord.gg/aJvAYeycyY](https://discord.gg/aJvAYeycyY)
Need support or have questions: If you have questions or need support, feel free to reach out:
- GitHub Issues: Report bugs or request features here. - GitHub Issues: Report bugs or request features here.
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com) - Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
@@ -148,86 +139,77 @@ Special shoutout to the JF official clients for being an inspiration to ours.
Thanks to the following contributors for their significant contributions: Thanks to the following contributors for their significant contributions:
<div align="left">
<table> <table>
<tr> <tr
style="
display: flex;
justify-content: space-around;
align-items: center;
flex-wrap: wrap;
"
>
<td align="center"> <td align="center">
<a href="https://github.com/Alexk2309"> <a href="https://github.com/Alexk2309">
<img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/Alexk2309.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Alexk2309</b></sub> <br /><sub><b>@Alexk2309</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/herrrta"> <a href="https://github.com/herrrta">
<img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/herrrta.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@herrrta</b></sub> <br /><sub><b>@herrrta</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/lostb1t"> <a href="https://github.com/lostb1t">
<img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/lostb1t.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@lostb1t</b></sub> <br /><sub><b>@lostb1t</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/Simon-Eklundh"> <a href="https://github.com/Simon-Eklundh">
<img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/Simon-Eklundh.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Simon-Eklundh</b></sub> <br /><sub><b>@Simon-Eklundh</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/topiga"> <a href="https://github.com/topiga">
<img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/topiga.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@topiga</b></sub> <br /><sub><b>@topiga</b></sub>
</a> </a>
</td> </td>
<td align="center">
<a href="https://github.com/lancechant">
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@lancechant</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center"> <td align="center">
<a href="https://github.com/simoncaron"> <a href="https://github.com/simoncaron">
<img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/simoncaron.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@simoncaron</b></sub> <br /><sub><b>@simoncaron</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/jakequade"> <a href="https://github.com/jakequade">
<img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/jakequade.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@jakequade</b></sub> <br /><sub><b>@jakequade</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/Ryan0204"> <a href="https://github.com/Ryan0204">
<img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/Ryan0204.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@Ryan0204</b></sub> <br /><sub><b>@Ryan0204</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/retardgerman"> <a href="https://github.com/retardgerman">
<img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/retardgerman.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@retardgerman</b></sub> <br /><sub><b>@retardgerman</b></sub>
</a> </a>
</td> </td>
<td align="center"> <td align="center">
<a href="https://github.com/whoopsi-daisy"> <a href="https://github.com/whoopsi-daisy">
<img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" /> <img src="https://github.com/whoopsi-daisy.png?size=80" width="80" style="border-radius: 50%;" />
<br /><sub><b>@whoopsi-daisy</b></sub> <br /><sub><b>@whoopsi-daisy</b></sub>
</a> </a>
</td> </td>
<td align="center">
<a href="https://github.com/Gauvino">
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
<br /><sub><b>@Gauvino</b></sub>
</a>
</td>
</tr> </tr>
</table> </table>
</div>
And all other developers who have contributed to Streamyfin, thank you for your contributions. And all other developers who have contributed to Streamyfin, thank you for your contributions.
@@ -246,4 +228,4 @@ I'd also like to thank the following people and projects for their contributions
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels. Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
## 🤝 Sponsorship ## 🤝 Sponsorship
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster) VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur)

View File

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

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Streamyfin", "name": "Streamyfin",
"slug": "streamyfin", "slug": "streamyfin",
"version": "0.32.1", "version": "0.29.1",
"orientation": "default", "orientation": "default",
"icon": "./assets/images/icon.png", "icon": "./assets/images/icon.png",
"scheme": "streamyfin", "scheme": "streamyfin",
@@ -29,19 +29,18 @@
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "com.fredrikburmester.streamyfin", "bundleIdentifier": "com.fredrikburmester.streamyfin",
"icon": { "icon": {
"dark": "./assets/images/icon-ios-plain.png", "dark": "./assets/images/icon-plain.png",
"light": "./assets/images/icon-ios-light.png", "light": "./assets/images/icon-ios-light.png",
"tinted": "./assets/images/icon-ios-tinted.png" "tinted": "./assets/images/icon-ios-tinted.png"
}, }
"appleTeamId": "MWD5K362T8"
}, },
"android": { "android": {
"jsEngine": "hermes", "jsEngine": "hermes",
"versionCode": 62, "versionCode": 56,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/images/icon-android-plain.png", "foregroundImage": "./assets/images/icon-plain.png",
"monochromeImage": "./assets/images/icon-android-themed.png", "monochromeImage": "./assets/images/icon-mono.png",
"backgroundColor": "#2E2E2E" "backgroundColor": "#464646"
}, },
"package": "com.fredrikburmester.streamyfin", "package": "com.fredrikburmester.streamyfin",
"permissions": [ "permissions": [
@@ -52,7 +51,6 @@
"googleServicesFile": "./google-services.json" "googleServicesFile": "./google-services.json"
}, },
"plugins": [ "plugins": [
"@react-native-tvos/config-tv",
"expo-router", "expo-router",
"expo-font", "expo-font",
[ [
@@ -114,15 +112,17 @@
} }
} }
], ],
["react-native-bottom-tabs"],
["./plugins/withChangeNativeAndroidTextToWhite.js"], ["./plugins/withChangeNativeAndroidTextToWhite.js"],
["./plugins/withAndroidManifest.js"], ["./plugins/withAndroidManifest.js"],
["./plugins/withTrustLocalCerts.js"], ["./plugins/withTrustLocalCerts.js"],
["./plugins/withGradleProperties.js"], ["./plugins/withGradleProperties.js"],
["./plugins/withRNBackgroundDownloader.js"],
[ [
"expo-splash-screen", "expo-splash-screen",
{ {
"backgroundColor": "#2e2e2e", "backgroundColor": "#2e2e2e",
"image": "./assets/images/icon-ios-plain.png", "image": "./assets/images/StreamyFinFinal.png",
"imageWidth": 100 "imageWidth": 100
} }
], ],
@@ -133,8 +133,13 @@
"color": "#9333EA" "color": "#9333EA"
} }
], ],
"./plugins/with-runtime-framework-headers.js", [
"react-native-bottom-tabs" "react-native-google-cast",
{
"useDefaultExpandedMediaControls": true
}
],
"expo-background-task"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true

View File

@@ -10,7 +10,7 @@ export default function SearchLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: !Platform.isTV, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.favorites"), headerTitle: t("tabs.favorites"),
headerLargeStyle: { headerLargeStyle: {

View File

@@ -20,7 +20,7 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: !Platform.isTV, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.home"), headerTitle: t("tabs.home"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
@@ -66,6 +66,12 @@ export default function IndexLayout() {
title: t("home.settings.settings_title"), title: t("home.settings.settings_title"),
}} }}
/> />
<Stack.Screen
name='settings/optimized-server/page'
options={{
title: "",
}}
/>
<Stack.Screen <Stack.Screen
name='settings/marlin-search/page' name='settings/marlin-search/page'
options={{ options={{

View File

@@ -23,12 +23,12 @@ export default function page() {
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>( const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
{}, {},
); );
const { getDownloadedItems, deleteItems } = useDownload(); const { downloadedFiles, deleteItems } = useDownload();
const series = useMemo(() => { const series = useMemo(() => {
try { try {
return ( return (
getDownloadedItems() downloadedFiles
?.filter((f) => f.item.SeriesId === seriesId) ?.filter((f) => f.item.SeriesId === seriesId)
?.sort( ?.sort(
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!, (a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
@@ -37,37 +37,7 @@ export default function page() {
} catch { } catch {
return []; return [];
} }
}, [getDownloadedItems]); }, [downloadedFiles]);
// Group episodes by season in a single pass
const seasonGroups = useMemo(() => {
const groups: Record<number, BaseItemDto[]> = {};
series.forEach((episode) => {
const seasonNumber = episode.item.ParentIndexNumber;
if (seasonNumber !== undefined && seasonNumber !== null) {
if (!groups[seasonNumber]) {
groups[seasonNumber] = [];
}
groups[seasonNumber].push(episode.item);
}
});
// Sort episodes within each season
Object.values(groups).forEach((episodes) => {
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
});
return groups;
}, [series]);
// Get unique seasons (just the season numbers, sorted)
const uniqueSeasons = useMemo(() => {
const seasonNumbers = Object.keys(seasonGroups)
.map(Number)
.sort((a, b) => a - b);
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
}, [seasonGroups]);
const seasonIndex = const seasonIndex =
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] || seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
@@ -75,8 +45,20 @@ export default function page() {
""; "";
const groupBySeason = useMemo<BaseItemDto[]>(() => { const groupBySeason = useMemo<BaseItemDto[]>(() => {
return seasonGroups[Number(seasonIndex)] ?? []; const seasons: Record<string, BaseItemDto[]> = {};
}, [seasonGroups, seasonIndex]);
series?.forEach((episode) => {
if (!seasons[episode.item.ParentIndexNumber!]) {
seasons[episode.item.ParentIndexNumber!] = [];
}
seasons[episode.item.ParentIndexNumber!].push(episode.item);
});
return (
seasons[seasonIndex]?.sort((a, b) => a.IndexNumber! - b.IndexNumber!) ??
[]
);
}, [series, seasonIndex]);
const initialSeasonIndex = useMemo( const initialSeasonIndex = useMemo(
() => () =>
@@ -120,7 +102,7 @@ export default function page() {
<View className='flex flex-row items-center justify-start my-2 px-4'> <View className='flex flex-row items-center justify-start my-2 px-4'>
<SeasonDropdown <SeasonDropdown
item={series[0].item} item={series[0].item}
seasons={uniqueSeasons} seasons={series.map((s) => s.item)}
state={seasonIndexState} state={seasonIndexState}
initialSeasonIndex={initialSeasonIndex!} initialSeasonIndex={initialSeasonIndex!}
onSelect={(season) => { onSelect={(season) => {

View File

@@ -6,69 +6,38 @@ import {
BottomSheetView, BottomSheetView,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { useNavigation, useRouter } from "expo-router"; import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, ScrollView, TouchableOpacity, View } from "react-native"; 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 { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads"; import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
import { DownloadSize } from "@/components/downloads/DownloadSize"; 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 { useDownload } from "@/providers/DownloadProvider"; import { type DownloadedItem, useDownload } from "@/providers/DownloadProvider";
import { type DownloadedItem } from "@/providers/Downloads/types";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { writeToLog } from "@/utils/log"; import { writeToLog } from "@/utils/log";
export default function page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation(); const { t } = useTranslation();
const [queue, setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const { const { removeProcess, downloadedFiles, deleteFileByType } = useDownload();
removeProcess,
getDownloadedItems,
deleteFileByType,
deleteAllFiles,
} = useDownload();
const router = useRouter(); const router = useRouter();
const [settings] = useSettings();
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const [showMigration, setShowMigration] = useState(false);
const migration_20241124 = () => {
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
t("home.downloads.new_app_version_requires_re_download_description"),
[
{
text: t("home.downloads.back"),
onPress: () => {
setShowMigration(false);
router.back();
},
},
{
text: t("home.downloads.delete"),
style: "destructive",
onPress: async () => {
await deleteAllFiles();
setShowMigration(false);
},
},
],
);
};
const downloadedFiles = getDownloadedItems();
const movies = useMemo(() => { const movies = useMemo(() => {
try { try {
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || []; return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
} catch { } catch {
setShowMigration(true); migration_20241124();
return []; return [];
} }
}, [downloadedFiles]); }, [downloadedFiles]);
@@ -85,11 +54,13 @@ export default function page() {
}); });
return Object.values(series); return Object.values(series);
} catch { } catch {
setShowMigration(true); migration_20241124();
return []; return [];
} }
}, [downloadedFiles]); }, [downloadedFiles]);
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({
headerRight: () => ( headerRight: () => (
@@ -100,12 +71,6 @@ export default function page() {
}); });
}, [downloadedFiles]); }, [downloadedFiles]);
useEffect(() => {
if (showMigration) {
migration_20241124();
}
}, [showMigration]);
const deleteMovies = () => const deleteMovies = () =>
deleteFileByType("Movie") deleteFileByType("Movie")
.then(() => .then(() =>
@@ -133,10 +98,16 @@ export default function page() {
return ( return (
<> <>
<View style={{ flex: 1 }}> <ScrollView
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'> contentContainerStyle={{
<View className='py-4'> paddingLeft: insets.left,
<View className='mb-4 flex flex-col space-y-4 px-4'> paddingRight: insets.right,
paddingBottom: 100,
}}
>
<View className='py-4'>
<View className='mb-4 flex flex-col space-y-4 px-4'>
{settings?.downloadMethod === DownloadMethod.Remux && (
<View className='bg-neutral-900 p-4 rounded-2xl'> <View className='bg-neutral-900 p-4 rounded-2xl'>
<Text className='text-lg font-bold'> <Text className='text-lg font-bold'>
{t("home.downloads.queue")} {t("home.downloads.queue")}
@@ -180,74 +151,70 @@ export default function page() {
</Text> </Text>
)} )}
</View> </View>
)}
<ActiveDownloads /> <ActiveDownloads />
</View>
{movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<TouchableItemRouter
item={item.item}
isOffline
key={item.item.Id}
>
<MovieCard item={item.item} />
</TouchableItemRouter>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View> </View>
</ScrollView>
</View> {movies.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.movies")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>{movies?.length}</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{movies?.map((item) => (
<View className='mb-2 last:mb-0' key={item.item.Id}>
<MovieCard item={item.item} />
</View>
))}
</View>
</ScrollView>
</View>
)}
{groupedBySeries.length > 0 && (
<View className='mb-4'>
<View className='flex flex-row items-center justify-between mb-2 px-4'>
<Text className='text-lg font-bold'>
{t("home.downloads.tvseries")}
</Text>
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
<Text className='text-xs font-bold'>
{groupedBySeries?.length}
</Text>
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View className='px-4 flex flex-row'>
{groupedBySeries?.map((items) => (
<View
className='mb-2 last:mb-0'
key={items[0].item.SeriesId}
>
<SeriesCard
items={items.map((i) => i.item)}
key={items[0].item.SeriesId}
/>
</View>
))}
</View>
</ScrollView>
</View>
)}
{downloadedFiles?.length === 0 && (
<View className='flex px-4'>
<Text className='opacity-50'>
{t("home.downloads.no_downloaded_items")}
</Text>
</View>
)}
</View>
</ScrollView>
<BottomSheetModal <BottomSheetModal
ref={bottomSheetModalRef} ref={bottomSheetModalRef}
enableDynamicSizing enableDynamicSizing
@@ -282,3 +249,23 @@ export default function page() {
</> </>
); );
} }
function migration_20241124() {
const router = useRouter();
const { deleteAllFiles } = useDownload();
Alert.alert(
t("home.downloads.new_app_version_requires_re_download"),
t("home.downloads.new_app_version_requires_re_download_description"),
[
{
text: t("home.downloads.back"),
onPress: () => router.back(),
},
{
text: t("home.downloads.delete"),
style: "destructive",
onPress: async () => await deleteAllFiles(),
},
],
);
}

View File

@@ -3,7 +3,7 @@ import { Image } from "expo-image";
import { useFocusEffect, useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Linking, Platform, TouchableOpacity, View } from "react-native"; import { Linking, TouchableOpacity, View } from "react-native";
import { Button } from "@/components/Button"; import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
@@ -19,9 +19,7 @@ export default function page() {
); );
return ( return (
<View <View className='bg-neutral-900 h-full py-16 px-4 space-y-8'>
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
>
<View> <View>
<Text className='text-3xl font-bold text-center mb-2'> <Text className='text-3xl font-bold text-center mb-2'>
{t("home.intro.welcome_to_streamyfin")} {t("home.intro.welcome_to_streamyfin")}
@@ -51,50 +49,42 @@ export default function page() {
</Text> </Text>
</View> </View>
</View> </View>
{!Platform.isTV && ( <View className='flex flex-row items-center mt-4'>
<> <View
<View className='flex flex-row items-center mt-4'> style={{
<View width: 50,
style={{ height: 50,
width: 50, }}
height: 50, className='flex items-center justify-center'
}} >
className='flex items-center justify-center' <Ionicons name='cloud-download-outline' size={32} color='white' />
> </View>
<Ionicons <View className='shrink ml-2'>
name='cloud-download-outline' <Text className='font-bold mb-1'>
size={32} {t("home.intro.downloads_feature_title")}
color='white' </Text>
/> <Text className='shrink text-xs'>
</View> {t("home.intro.downloads_feature_description")}
<View className='shrink ml-2'> </Text>
<Text className='font-bold mb-1'> </View>
{t("home.intro.downloads_feature_title")} </View>
</Text> <View className='flex flex-row items-center mt-4'>
<Text className='shrink text-xs'> <View
{t("home.intro.downloads_feature_description")} style={{
</Text> width: 50,
</View> height: 50,
</View> }}
<View className='flex flex-row items-center mt-4'> className='flex items-center justify-center'
<View >
style={{ <Feather name='cast' size={28} color={"white"} />
width: 50, </View>
height: 50, <View className='shrink ml-2'>
}} <Text className='font-bold mb-1'>Chromecast</Text>
className='flex items-center justify-center' <Text className='shrink text-xs'>
> {t("home.intro.chromecast_feature_description")}
<Feather name='cast' size={28} color={"white"} /> </Text>
</View> </View>
<View className='shrink ml-2'> </View>
<Text className='font-bold mb-1'>Chromecast</Text>
<Text className='shrink text-xs'>
{t("home.intro.chromecast_feature_description")}
</Text>
</View>
</View>
</>
)}
<View className='flex flex-row items-center mt-4'> <View className='flex flex-row items-center mt-4'>
<View <View
style={{ style={{
@@ -109,22 +99,19 @@ export default function page() {
<Text className='font-bold mb-1'> <Text className='font-bold mb-1'>
{t("home.intro.centralised_settings_plugin_title")} {t("home.intro.centralised_settings_plugin_title")}
</Text> </Text>
<View className='flex-row flex-wrap items-baseline'> <Text className='shrink text-xs'>
<Text className='shrink text-xs'> {t("home.intro.centralised_settings_plugin_description")}{" "}
{t("home.intro.centralised_settings_plugin_description")}{" "} <Text
</Text> className='text-purple-600'
<TouchableOpacity
onPress={() => { onPress={() => {
Linking.openURL( Linking.openURL(
"https://github.com/streamyfin/jellyfin-plugin-streamyfin", "https://github.com/streamyfin/jellyfin-plugin-streamyfin",
); );
}} }}
> >
<Text className='text-xs text-purple-600 underline'> {t("home.intro.read_more")}
{t("home.intro.read_more")} </Text>
</Text> </Text>
</TouchableOpacity>
</View>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -434,6 +434,8 @@ const TranscodingStreamView = ({
isTranscoding, isTranscoding,
properties, properties,
transcodeProperties, transcodeProperties,
value,
transcodeValue,
}: TranscodingStreamViewProps) => { }: TranscodingStreamViewProps) => {
return ( return (
<View className='flex flex-col pt-2 first:pt-0'> <View className='flex flex-col pt-2 first:pt-0'>

View File

@@ -2,7 +2,7 @@ import { useNavigation, useRouter } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
import { Platform, ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ListGroup } from "@/components/list/ListGroup"; import { ListGroup } from "@/components/list/ListGroup";
@@ -73,13 +73,13 @@ export default function settings() {
<OtherSettings /> <OtherSettings />
{!Platform.isTV && <DownloadSettings />} <DownloadSettings />
<PluginSettings /> <PluginSettings />
<AppLanguageSelector /> <AppLanguageSelector />
{!Platform.isTV && <ChromecastSettings />} <ChromecastSettings />
<ListGroup title={"Intro"}> <ListGroup title={"Intro"}>
<ListItem <ListItem
@@ -112,7 +112,7 @@ export default function settings() {
</ListGroup> </ListGroup>
</View> </View>
{!Platform.isTV && <StorageSettings />} <StorageSettings />
</View> </View>
</ScrollView> </ScrollView>
); );

View File

@@ -1,7 +1,7 @@
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import * as Sharing from "expo-sharing"; import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollView, TouchableOpacity, View } from "react-native"; import { ScrollView, TouchableOpacity, View } from "react-native";
import Collapsible from "react-native-collapsible"; import Collapsible from "react-native-collapsible";
@@ -10,14 +10,11 @@ import { FilterButton } from "@/components/filters/FilterButton";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { LogLevel, useLog, writeErrorLog } from "@/utils/log"; import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
export default function Page() { export default function page() {
const navigation = useNavigation(); const navigation = useNavigation();
const { logs } = useLog(); const { logs } = useLog();
const { t } = useTranslation(); const { t } = useTranslation();
const orderFilterId = useId();
const levelsFilterId = useId();
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"]; const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
const codeBlockStyle = { const codeBlockStyle = {
backgroundColor: "#000", backgroundColor: "#000",
@@ -28,12 +25,10 @@ export default function Page() {
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [state, setState] = useState<Record<string, boolean>>({}); const [state, setState] = useState<Record<string, boolean>>({});
const [order, setOrder] = useState<"asc" | "desc">("desc"); const [order, setOrder] = useState<"asc" | "desc">("desc");
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels); const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
const _orderId = useId();
const _levelsId = useId();
const filteredLogs = useMemo( const filteredLogs = useMemo(
() => () =>
logs logs
@@ -78,7 +73,7 @@ export default function Page() {
<> <>
<View className='flex flex-row justify-end py-2 px-4 space-x-2'> <View className='flex flex-row justify-end py-2 px-4 space-x-2'>
<FilterButton <FilterButton
id={orderFilterId} id='order'
queryKey='log' queryKey='log'
queryFn={async () => ["asc", "desc"]} queryFn={async () => ["asc", "desc"]}
set={(values) => setOrder(values[0])} set={(values) => setOrder(values[0])}
@@ -88,7 +83,7 @@ export default function Page() {
showSearch={false} showSearch={false}
/> />
<FilterButton <FilterButton
id={levelsFilterId} id='levels'
queryKey='log' queryKey='log'
queryFn={async () => defaultLevels} queryFn={async () => defaultLevels}
set={setLevels} set={setLevels}
@@ -127,7 +122,7 @@ export default function Page() {
{new Date(log.timestamp).toLocaleString()} {new Date(log.timestamp).toLocaleString()}
</Text> </Text>
</View> </View>
<Text selectable className='text-xs'> <Text uiTextView selectable className='text-xs'>
{log.message} {log.message}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>

View File

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

View File

@@ -112,7 +112,7 @@ const page: React.FC = () => {
recursive: true, recursive: true,
genres: selectedGenres, genres: selectedGenres,
tags: selectedTags, tags: selectedTags,
years: selectedYears.map((year) => Number.parseInt(year, 10)), years: selectedYears.map((year) => Number.parseInt(year)),
includeItemTypes: ["Movie", "Series"], includeItemTypes: ["Movie", "Series"],
}); });

View File

@@ -1,4 +1,7 @@
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -11,16 +14,30 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { ItemContent } from "@/components/ItemContent"; import { ItemContent } from "@/components/ItemContent";
import { useItemQuery } from "@/hooks/useItemQuery"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const Page: React.FC = () => { const Page: React.FC = () => {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const { id } = useLocalSearchParams() as { id: string }; const { id } = useLocalSearchParams() as { id: string };
const { t } = useTranslation(); const { t } = useTranslation();
const { offline } = useLocalSearchParams() as { offline?: string }; const { data: item, isError } = useQuery({
const isOffline = offline === "true"; queryKey: ["item", id],
queryFn: async () => {
if (!api || !user || !id) return;
const res = await getUserLibraryApi(api).getItem({
itemId: id,
userId: user?.Id,
});
const { data: item, isError } = useItemQuery(id, isOffline); return res.data;
},
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const opacity = useSharedValue(1); const opacity = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
@@ -90,7 +107,7 @@ const Page: React.FC = () => {
<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 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' />
</Animated.View> </Animated.View>
{item && <ItemContent item={item} isOffline={isOffline} />} {item && <ItemContent item={item} />}
</View> </View>
); );
}; };

View File

@@ -17,7 +17,7 @@ export default function page() {
const local = useLocalSearchParams(); const local = useLocalSearchParams();
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { companyId, image, type } = local as unknown as { const { companyId, name, image, type } = local as unknown as {
companyId: string; companyId: string;
name: string; name: string;
image: string; image: string;

View File

@@ -221,7 +221,11 @@ const Page: React.FC = () => {
| TvDetails | TvDetails
} }
/> />
<Text selectable className='font-bold text-2xl mb-1'> <Text
uiTextView
selectable
className='font-bold text-2xl mb-1'
>
{mediaTitle} {mediaTitle}
</Text> </Text>
<Text className='opacity-50'>{releaseYear}</Text> <Text className='opacity-50'>{releaseYear}</Text>
@@ -252,28 +256,26 @@ const Page: React.FC = () => {
) : ( ) : (
details?.mediaInfo?.jellyfinMediaId && ( details?.mediaInfo?.jellyfinMediaId && (
<View className='flex flex-row space-x-2 mt-4'> <View className='flex flex-row space-x-2 mt-4'>
{!Platform.isTV && ( <Button
<Button className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100' color='transparent'
color='transparent' onPress={() => bottomSheetModalRef?.current?.present()}
onPress={() => bottomSheetModalRef?.current?.present()} iconLeft={
iconLeft={ <Ionicons
<Ionicons name='warning-outline'
name='warning-outline' size={20}
size={20} color='white'
color='white' />
/> }
} style={{
style={{ borderWidth: 1,
borderWidth: 1, borderStyle: "solid",
borderStyle: "solid", }}
}} >
> <Text className='text-sm'>
<Text className='text-sm'> {t("jellyseerr.report_issue_button")}
{t("jellyseerr.report_issue_button")} </Text>
</Text> </Button>
</Button>
)}
<Button <Button
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100' className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
onPress={() => { onPress={() => {
@@ -331,95 +333,92 @@ const Page: React.FC = () => {
}} }}
onDismiss={() => _setRequestBody(undefined)} onDismiss={() => _setRequestBody(undefined)}
/> />
{!Platform.isTV && ( <BottomSheetModal
// This is till it's fixed because the menu isn't selectable on TV ref={bottomSheetModalRef}
<BottomSheetModal enableDynamicSizing
ref={bottomSheetModalRef} handleIndicatorStyle={{
enableDynamicSizing backgroundColor: "white",
handleIndicatorStyle={{ }}
backgroundColor: "white", backgroundStyle={{
}} backgroundColor: "#171717",
backgroundStyle={{ }}
backgroundColor: "#171717", backdropComponent={renderBackdrop}
}} >
backdropComponent={renderBackdrop} <BottomSheetView>
> <View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
<BottomSheetView> <View>
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'> <Text className='font-bold text-2xl text-neutral-100'>
<View> {t("jellyseerr.whats_wrong")}
<Text className='font-bold text-2xl text-neutral-100'> </Text>
{t("jellyseerr.whats_wrong")}
</Text>
</View>
<View className='flex flex-col space-y-2 items-start'>
<View className='flex flex-col'>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View> </View>
</BottomSheetView> <View className='flex flex-col space-y-2 items-start'>
</BottomSheetModal> <View className='flex flex-col'>
)} <DropdownMenu.Root>
<DropdownMenu.Trigger>
<View className='flex flex-col'>
<Text className='opacity-50 mb-1 text-xs'>
{t("jellyseerr.issue_type")}
</Text>
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
<Text style={{}} className='' numberOfLines={1}>
{issueType
? IssueTypeName[issueType]
: t("jellyseerr.select_an_issue")}
</Text>
</TouchableOpacity>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content
loop={false}
side='bottom'
align='center'
alignOffset={0}
avoidCollisions={true}
collisionPadding={0}
sideOffset={0}
>
<DropdownMenu.Label>
{t("jellyseerr.types")}
</DropdownMenu.Label>
{Object.entries(IssueTypeName)
.reverse()
.map(([key, value], _idx) => (
<DropdownMenu.Item
key={value}
onSelect={() =>
setIssueType(key as unknown as IssueType)
}
>
<DropdownMenu.ItemTitle>
{value}
</DropdownMenu.ItemTitle>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
<BottomSheetTextInput
multiline
maxLength={254}
style={{ color: "white" }}
clearButtonMode='always'
placeholder={t("jellyseerr.describe_the_issue")}
placeholderTextColor='#9CA3AF'
// Issue with multiline + Textinput inside a portal
// https://github.com/callstack/react-native-paper/issues/1668
defaultValue={issueMessage}
onChangeText={setIssueMessage}
/>
</View>
</View>
<Button className='mt-auto' onPress={submitIssue} color='purple'>
{t("jellyseerr.submit_button")}
</Button>
</View>
</BottomSheetView>
</BottomSheetModal>
</View> </View>
); );
}; };

View File

@@ -21,13 +21,14 @@ export default function page() {
const { const {
jellyseerrApi, jellyseerrApi,
jellyseerrUser,
jellyseerrRegion: region, jellyseerrRegion: region,
jellyseerrLocale: locale, jellyseerrLocale: locale,
} = useJellyseerr(); } = useJellyseerr();
const { personId } = local as { personId: string }; const { personId } = local as { personId: string };
const { data } = useQuery({ const { data, isLoading, isFetching } = useQuery({
queryKey: ["jellyseerr", "person", personId], queryKey: ["jellyseerr", "person", personId],
queryFn: async () => ({ queryFn: async () => ({
details: await jellyseerrApi?.personDetails(personId), details: await jellyseerrApi?.personDetails(personId),

View File

@@ -1,51 +0,0 @@
import {
createMaterialTopTabNavigator,
MaterialTopTabNavigationEventMap,
MaterialTopTabNavigationOptions,
} from "@react-navigation/material-top-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { Stack, withLayoutContext } from "expo-router";
const { Navigator } = createMaterialTopTabNavigator();
export const Tab = withLayoutContext<
MaterialTopTabNavigationOptions,
typeof Navigator,
TabNavigationState<ParamListBase>,
MaterialTopTabNavigationEventMap
>(Navigator);
const Layout = () => {
return (
<>
<Stack.Screen options={{ title: "Live TV" }} />
<Tab
initialRouteName='programs'
keyboardDismissMode='none'
screenOptions={{
tabBarBounces: true,
tabBarLabelStyle: { fontSize: 10 },
tabBarItemStyle: {
width: 100,
},
tabBarStyle: { backgroundColor: "black" },
animationEnabled: true,
lazy: true,
swipeEnabled: true,
tabBarIndicatorStyle: { backgroundColor: "#9334E9" },
tabBarScrollEnabled: true,
}}
>
<Tab.Screen name='programs' />
<Tab.Screen name='guide' />
<Tab.Screen name='channels' />
<Tab.Screen name='recordings' />
</Tab>
</>
);
};
export default Layout;

View File

@@ -1,55 +0,0 @@
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { FlashList } from "@shopify/flash-list";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const _insets = useSafeAreaInsets();
const { data: channels } = useQuery({
queryKey: ["livetv", "channels"],
queryFn: async () => {
const res = await getLiveTvApi(api!).getLiveTvChannels({
startIndex: 0,
limit: 500,
enableFavoriteSorting: true,
userId: user?.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
});
return (
<View className='flex flex-1'>
<FlashList
data={channels?.Items}
estimatedItemSize={76}
renderItem={({ item }) => (
<View className='flex flex-row items-center px-4 mb-2'>
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
<ItemImage
style={{
aspectRatio: "1/1",
width: 60,
borderRadius: 8,
}}
item={item}
/>
</View>
<Text className='font-bold'>{item.Name}</Text>
</View>
)}
/>
</View>
);
}

View File

@@ -1,206 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Dimensions, ScrollView, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { HourHeader } from "@/components/livetv/HourHeader";
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
const HOUR_HEIGHT = 30;
const ITEMS_PER_PAGE = 20;
const MemoizedLiveTVGuideRow = React.memo(LiveTVGuideRow);
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const [date, _setDate] = useState<Date>(new Date());
const [currentPage, setCurrentPage] = useState(1);
const { data: channels } = useQuery({
queryKey: ["livetv", "channels", currentPage],
queryFn: async () => {
const res = await getLiveTvApi(api!).getLiveTvChannels({
startIndex: (currentPage - 1) * ITEMS_PER_PAGE,
limit: ITEMS_PER_PAGE,
enableFavoriteSorting: true,
userId: user?.Id,
addCurrentProgram: false,
enableUserData: false,
enableImageTypes: ["Primary"],
});
return res.data;
},
});
const { data: programs } = useQuery({
queryKey: ["livetv", "programs", date, currentPage],
queryFn: async () => {
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const now = new Date();
const isToday = startOfDay.toDateString() === now.toDateString();
const res = await getLiveTvApi(api!).getPrograms({
getProgramsDto: {
MaxStartDate: endOfDay.toISOString(),
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
Boolean,
) as string[],
ImageTypeLimit: 1,
EnableImages: false,
SortBy: ["StartDate"],
EnableTotalRecordCount: false,
EnableUserData: false,
},
});
return res.data;
},
enabled: !!channels,
});
const screenWidth = Dimensions.get("window").width;
const [scrollX, setScrollX] = useState(0);
const handleNextPage = useCallback(() => {
setCurrentPage((prev) => prev + 1);
}, []);
const handlePrevPage = useCallback(() => {
setCurrentPage((prev) => Math.max(1, prev - 1));
}, []);
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
}}
>
<PageButtons
currentPage={currentPage}
onPrevPage={handlePrevPage}
onNextPage={handleNextPage}
isNextDisabled={
!channels || (channels?.Items?.length || 0) < ITEMS_PER_PAGE
}
/>
<View className='flex flex-row'>
<View className='flex flex-col w-[64px]'>
<View
style={{
height: HOUR_HEIGHT,
}}
className='bg-neutral-800'
/>
{channels?.Items?.map((c, i) => (
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
<ItemImage
style={{
width: "100%",
height: "100%",
resizeMode: "contain",
}}
item={c}
/>
</View>
))}
</View>
<ScrollView
style={{
width: screenWidth - 64,
}}
horizontal
scrollEnabled
onScroll={(e) => {
setScrollX(e.nativeEvent.contentOffset.x);
}}
>
<View className='flex flex-col'>
<HourHeader height={HOUR_HEIGHT} />
{channels?.Items?.map((c, _i) => (
<MemoizedLiveTVGuideRow
channel={c}
programs={programs?.Items}
key={c.Id}
scrollX={scrollX}
/>
))}
</View>
</ScrollView>
</View>
</ScrollView>
);
}
interface PageButtonsProps {
currentPage: number;
onPrevPage: () => void;
onNextPage: () => void;
isNextDisabled: boolean;
}
const PageButtons: React.FC<PageButtonsProps> = ({
currentPage,
onPrevPage,
onNextPage,
isNextDisabled,
}) => {
const { t } = useTranslation();
return (
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
<TouchableOpacity
onPress={onPrevPage}
disabled={currentPage === 1}
className='flex flex-row items-center'
>
<Ionicons
name='chevron-back'
size={24}
color={currentPage === 1 ? "gray" : "white"}
/>
<Text
className={`ml-1 ${
currentPage === 1 ? "text-gray-500" : "text-white"
}`}
>
{t("live_tv.previous")}
</Text>
</TouchableOpacity>
<Text className='text-white'>Page {currentPage}</Text>
<TouchableOpacity
onPress={onNextPage}
disabled={isNextDisabled}
className='flex flex-row items-center'
>
<Text
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
>
{t("live_tv.next")}
</Text>
<Ionicons
name='chevron-forward'
size={24}
color={isNextDisabled ? "gray" : "white"}
/>
</TouchableOpacity>
</View>
);
};

View File

@@ -1,145 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
export default function page() {
const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom);
const insets = useSafeAreaInsets();
const { t } = useTranslation();
return (
<ScrollView
nestedScrollEnabled
contentInsetAdjustmentBehavior='automatic'
key={"home"}
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
paddingBottom: 16,
paddingTop: 8,
}}
>
<View className='flex flex-col space-y-2'>
<ScrollingCollectionList
queryKey={["livetv", "recommended"]}
title={t("live_tv.on_now")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getRecommendedPrograms({
userId: user?.Id,
isAiring: true,
limit: 24,
imageTypeLimit: 1,
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
});
return res.data.Items || [];
}}
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "shows"]}
title={t("live_tv.shows")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isMovie: false,
isSeries: true,
isSports: false,
isNews: false,
isKids: false,
enableTotalRecordCount: false,
fields: ["ChannelInfo", "PrimaryImageAspectRatio"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "movies"]}
title={t("live_tv.movies")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isMovie: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "sports"]}
title={t("live_tv.sports")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isSports: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "kids"]}
title={t("live_tv.for_kids")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isKids: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation='horizontal'
/>
<ScrollingCollectionList
queryKey={["livetv", "news"]}
title={t("live_tv.news")}
queryFn={async () => {
if (!api) return [] as BaseItemDto[];
const res = await getLiveTvApi(api).getLiveTvPrograms({
userId: user?.Id,
hasAired: false,
limit: 9,
isNews: true,
enableTotalRecordCount: false,
fields: ["ChannelInfo"],
enableImageTypes: ["Primary", "Thumb", "Backdrop"],
});
return res.data.Items || [];
}}
orientation='horizontal'
/>
</View>
</ScrollView>
);
}

View File

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

View File

@@ -69,18 +69,10 @@ const page: React.FC = () => {
seriesId: item?.Id!, seriesId: item?.Id!,
userId: user?.Id!, userId: user?.Id!,
enableUserData: true, enableUserData: true,
// Note: Including trick play is necessary to enable trick play downloads fields: ["MediaSources", "MediaStreams", "Overview"],
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
}); });
return res?.data.Items || []; return res?.data.Items || [];
}, },
select: (data) =>
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
[...(data || [])].sort(
(a, b) =>
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
),
staleTime: 60, staleTime: 60,
enabled: !!api && !!user?.Id && !!item?.Id, enabled: !!api && !!user?.Id && !!item?.Id,
}); });
@@ -144,7 +136,7 @@ const page: React.FC = () => {
resizeMode: "contain", resizeMode: "contain",
}} }}
/> />
) : undefined ) : null
} }
> >
<View className='flex flex-col pt-4'> <View className='flex flex-col pt-4'>

View File

@@ -168,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, 10)), years: selectedYears.map((year) => Number.parseInt(year)),
includeItemTypes: itemType ? [itemType] : undefined, includeItemTypes: itemType ? [itemType] : undefined,
}); });

View File

@@ -20,7 +20,7 @@ export default function IndexLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: !Platform.isTV, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.library"), headerTitle: t("tabs.library"),
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
@@ -200,7 +200,7 @@ export default function IndexLayout() {
name='[libraryId]' name='[libraryId]'
options={{ options={{
title: "", title: "",
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,
@@ -213,7 +213,7 @@ export default function IndexLayout() {
name='collections/[collectionId]' name='collections/[collectionId]'
options={{ options={{
title: "", title: "",
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -13,7 +13,7 @@ export default function SearchLayout() {
<Stack.Screen <Stack.Screen
name='index' name='index'
options={{ options={{
headerShown: !Platform.isTV, headerShown: true,
headerLargeTitle: true, headerLargeTitle: true,
headerTitle: t("tabs.search"), headerTitle: t("tabs.search"),
headerLargeStyle: { headerLargeStyle: {
@@ -31,7 +31,7 @@ export default function SearchLayout() {
name='collections/[collectionId]' name='collections/[collectionId]'
options={{ options={{
title: "", title: "",
headerShown: !Platform.isTV, headerShown: true,
headerBlurEffect: "prominent", headerBlurEffect: "prominent",
headerTransparent: Platform.OS === "ios", headerTransparent: Platform.OS === "ios",
headerShadowVisible: false, headerShadowVisible: false,

View File

@@ -10,7 +10,6 @@ import { useAtom } from "jotai";
import { import {
useCallback, useCallback,
useEffect, useEffect,
useId,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
@@ -21,7 +20,6 @@ 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 ContinueWatchingPoster from "@/components/ContinueWatchingPoster"; import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
import { Input } from "@/components/common/Input";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter"; import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { FilterButton } from "@/components/filters/FilterButton"; import { FilterButton } from "@/components/filters/FilterButton";
@@ -59,9 +57,6 @@ export default function search() {
const { t } = useTranslation(); const { t } = useTranslation();
const searchFilterId = useId();
const orderFilterId = useId();
const { q } = params as { q: string }; const { q } = params as { q: string };
const [searchType, setSearchType] = useState<SearchType>("Library"); const [searchType, setSearchType] = useState<SearchType>("Library");
@@ -254,223 +249,205 @@ export default function search() {
}, [l1, l2, l3, l7, l8]); }, [l1, l2, l3, l7, l8]);
return ( return (
<ScrollView <>
keyboardDismissMode='on-drag' <ScrollView
contentInsetAdjustmentBehavior='automatic' keyboardDismissMode='on-drag'
contentContainerStyle={{ contentInsetAdjustmentBehavior='automatic'
paddingLeft: insets.left, contentContainerStyle={{
paddingRight: insets.right, paddingLeft: insets.left,
}} paddingRight: insets.right,
>
{/* <View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}}
> */}
{Platform.isTV && (
<Input
placeholder={t("search.search")}
onChangeText={(text) => {
router.setParams({ q: "" });
setSearch(text);
}}
keyboardType='default'
returnKeyType='done'
autoCapitalize='none'
clearButtonMode='while-editing'
maxLength={500}
/>
)}
<View
className='flex flex-col'
style={{
marginTop: Platform.OS === "android" ? 16 : 0,
}} }}
> >
{jellyseerrApi && ( <View
<ScrollView className='flex flex-col'
horizontal style={{
className='flex flex-row flex-wrap space-x-2 px-4 mb-2' marginTop: Platform.OS === "android" ? 16 : 0,
> }}
<TouchableOpacity onPress={() => setSearchType("Library")}> >
<Tag {jellyseerrApi && (
text={t("search.library")} <ScrollView
textClass='p-1' horizontal
className={ className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
searchType === "Library" ? "bg-purple-600" : undefined >
} <TouchableOpacity onPress={() => setSearchType("Library")}>
/> <Tag
</TouchableOpacity> text={t("search.library")}
<TouchableOpacity onPress={() => setSearchType("Discover")}> textClass='p-1'
<Tag className={
text={t("search.discover")} searchType === "Library" ? "bg-purple-600" : undefined
textClass='p-1' }
className={ />
searchType === "Discover" ? "bg-purple-600" : undefined </TouchableOpacity>
} <TouchableOpacity onPress={() => setSearchType("Discover")}>
/> <Tag
</TouchableOpacity> text={t("search.discover")}
{searchType === "Discover" && textClass='p-1'
!loading && className={
noResults && searchType === "Discover" ? "bg-purple-600" : undefined
debouncedSearch.length > 0 && ( }
<View className='flex flex-row justify-end items-center space-x-1'> />
<FilterButton </TouchableOpacity>
id={searchFilterId} {searchType === "Discover" &&
queryKey='jellyseerr_search' !loading &&
queryFn={async () => noResults &&
Object.keys(JellyseerrSearchSort).filter((v) => debouncedSearch.length > 0 && (
Number.isNaN(Number(v)), <View className='flex flex-row justify-end items-center space-x-1'>
) <FilterButton
} id='search'
set={(value) => setJellyseerrOrderBy(value[0])} queryKey='jellyseerr_search'
values={[jellyseerrOrderBy]} queryFn={async () =>
title={t("library.filters.sort_by")} Object.keys(JellyseerrSearchSort).filter((v) =>
renderItemLabel={(item) => Number.isNaN(Number(v)),
t(`home.settings.plugins.jellyseerr.order_by.${item}`) )
} }
showSearch={false} set={(value) => setJellyseerrOrderBy(value[0])}
/> values={[jellyseerrOrderBy]}
<FilterButton title={t("library.filters.sort_by")}
id={orderFilterId} renderItemLabel={(item) =>
queryKey='jellysearr_search' t(`home.settings.plugins.jellyseerr.order_by.${item}`)
queryFn={async () => ["asc", "desc"]} }
set={(value) => setJellyseerrSortOrder(value[0])} showSearch={false}
values={[jellyseerrSortOrder]} />
title={t("library.filters.sort_order")} <FilterButton
renderItemLabel={(item) => t(`library.filters.${item}`)} id='order'
showSearch={false} queryKey='jellysearr_search'
/> queryFn={async () => ["asc", "desc"]}
</View> set={(value) => setJellyseerrSortOrder(value[0])}
)} values={[jellyseerrSortOrder]}
</ScrollView> title={t("library.filters.sort_order")}
)} renderItemLabel={(item) => t(`library.filters.${item}`)}
showSearch={false}
/>
</View>
)}
</ScrollView>
)}
<View className='mt-2'> <View className='mt-2'>
<LoadingSkeleton isLoading={loading} /> <LoadingSkeleton isLoading={loading} />
</View>
{searchType === "Library" ? (
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<SearchItemWrapper
header={t("search.movies")}
items={movies}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className='flex flex-col w-28 mr-2'
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={series}
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={episodes}
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-44 mr-2'
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={collections}
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={actors}
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</View> </View>
) : (
<JellyserrIndexPage
searchQuery={debouncedSearch}
sortType={jellyseerrOrderBy}
order={jellyseerrSortOrder}
/>
)}
{searchType === "Library" && {searchType === "Library" ? (
(!loading && noResults && debouncedSearch.length > 0 ? ( <View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
<View> <SearchItemWrapper
<Text className='text-center text-lg font-bold mt-4'> header={t("search.movies")}
{t("search.no_results_found_for")} items={movies}
</Text> renderItem={(item: BaseItemDto) => (
<Text className='text-xs text-purple-600 text-center'> <TouchableItemRouter
"{debouncedSearch}" key={item.Id}
</Text> className='flex flex-col w-28 mr-2'
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={series}
header={t("search.series")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
<Text className='opacity-50 text-xs'>
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={episodes}
header={t("search.episodes")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-44 mr-2'
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={collections}
header={t("search.collections")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className='mt-2'>
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
items={actors}
header={t("search.actors")}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className='flex flex-col w-28 mr-2'
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
</View> </View>
) : debouncedSearch.length === 0 ? ( ) : (
<View className='mt-4 flex flex-col items-center space-y-2'> <JellyserrIndexPage
{exampleSearches.map((e) => ( searchQuery={debouncedSearch}
<TouchableOpacity sortType={jellyseerrOrderBy}
onPress={() => { order={jellyseerrSortOrder}
setSearch(e); />
searchBarRef.current?.setText(e); )}
}}
key={e} {searchType === "Library" &&
className='mb-2' (!loading && noResults && debouncedSearch.length > 0 ? (
> <View>
<Text className='text-purple-600'>{e}</Text> <Text className='text-center text-lg font-bold mt-4'>
</TouchableOpacity> {t("search.no_results_found_for")}
))} </Text>
</View> <Text className='text-xs text-purple-600 text-center'>
) : null)} "{debouncedSearch}"
</View> </Text>
</ScrollView> </View>
) : debouncedSearch.length === 0 ? (
<View className='mt-4 flex flex-col items-center space-y-2'>
{exampleSearches.map((e) => (
<TouchableOpacity
onPress={() => {
setSearch(e);
searchBarRef.current?.setText(e);
}}
key={e}
className='mb-2'
>
<Text className='text-purple-600'>{e}</Text>
</TouchableOpacity>
))}
</View>
) : null)}
</View>
</ScrollView>
</>
); );
} }

View File

@@ -1,26 +1,27 @@
import { import {
createNativeBottomTabNavigator, createNativeBottomTabNavigator,
type NativeBottomTabNavigationEventMap, type NativeBottomTabNavigationEventMap,
type NativeBottomTabNavigationOptions,
} from "@bottom-tabs/react-navigation"; } from "@bottom-tabs/react-navigation";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router"; import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
import { useCallback } from "react"; import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
const { Navigator } = createNativeBottomTabNavigator();
import type { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
import type {
ParamListBase,
TabNavigationState,
} from "@react-navigation/native";
import { SystemBars } from "react-native-edge-to-edge"; import { SystemBars } from "react-native-edge-to-edge";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useSettings } from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import { eventBus } from "@/utils/eventBus"; import { eventBus } from "@/utils/eventBus";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
const { Navigator } = createNativeBottomTabNavigator();
export const NativeTabs = withLayoutContext< export const NativeTabs = withLayoutContext<
NativeBottomTabNavigationOptions, BottomTabNavigationOptions,
typeof Navigator, typeof Navigator,
TabNavigationState<ParamListBase>, TabNavigationState<ParamListBase>,
NativeBottomTabNavigationEventMap NativeBottomTabNavigationEventMap
@@ -51,6 +52,7 @@ export default function TabLayout() {
<SystemBars hidden={false} style='light' /> <SystemBars hidden={false} style='light' />
<NativeTabs <NativeTabs
sidebarAdaptable={false} sidebarAdaptable={false}
ignoresTopSafeArea
tabBarStyle={{ tabBarStyle={{
backgroundColor: "#121212", backgroundColor: "#121212",
}} }}
@@ -59,7 +61,7 @@ export default function TabLayout() {
> >
<NativeTabs.Screen redirect name='index' /> <NativeTabs.Screen redirect name='index' />
<NativeTabs.Screen <NativeTabs.Screen
listeners={(_e) => ({ listeners={({ navigation }) => ({
tabPress: (_e) => { tabPress: (_e) => {
eventBus.emit("scrollToTop"); eventBus.emit("scrollToTop");
}, },
@@ -69,7 +71,8 @@ export default function TabLayout() {
title: t("tabs.home"), title: t("tabs.home"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/house.fill.png") ? ({ color, focused, size }) =>
require("@/assets/icons/house.fill.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "house.fill" } ? { sfSymbol: "house.fill" }
@@ -77,7 +80,7 @@ export default function TabLayout() {
}} }}
/> />
<NativeTabs.Screen <NativeTabs.Screen
listeners={(_e) => ({ listeners={({ navigation }) => ({
tabPress: (_e) => { tabPress: (_e) => {
eventBus.emit("searchTabPressed"); eventBus.emit("searchTabPressed");
}, },
@@ -87,7 +90,8 @@ export default function TabLayout() {
title: t("tabs.search"), title: t("tabs.search"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/magnifyingglass.png") ? ({ color, focused, size }) =>
require("@/assets/icons/magnifyingglass.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "magnifyingglass" } ? { sfSymbol: "magnifyingglass" }
@@ -100,7 +104,7 @@ export default function TabLayout() {
title: t("tabs.favorites"), title: t("tabs.favorites"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? ({ focused }) => ? ({ color, focused, size }) =>
focused focused
? require("@/assets/icons/heart.fill.png") ? require("@/assets/icons/heart.fill.png")
: require("@/assets/icons/heart.png") : require("@/assets/icons/heart.png")
@@ -116,7 +120,8 @@ export default function TabLayout() {
title: t("tabs.library"), title: t("tabs.library"),
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/server.rack.png") ? ({ color, focused, size }) =>
require("@/assets/icons/server.rack.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "rectangle.stack.fill" } ? { sfSymbol: "rectangle.stack.fill" }
@@ -127,10 +132,11 @@ export default function TabLayout() {
name='(custom-links)' name='(custom-links)'
options={{ options={{
title: t("tabs.custom_links"), title: t("tabs.custom_links"),
// @ts-expect-error
tabBarItemHidden: !settings?.showCustomMenuLinks, tabBarItemHidden: !settings?.showCustomMenuLinks,
tabBarIcon: tabBarIcon:
Platform.OS === "android" Platform.OS === "android"
? (_e) => require("@/assets/icons/list.png") ? ({ focused }) => require("@/assets/icons/list.png")
: ({ focused }) => : ({ focused }) =>
focused focused
? { sfSymbol: "list.dash.fill" } ? { sfSymbol: "list.dash.fill" }

View File

@@ -2,6 +2,7 @@ import {
type BaseItemDto, type BaseItemDto,
type MediaSourceInfo, type MediaSourceInfo,
PlaybackOrder, PlaybackOrder,
type PlaybackProgressInfo,
PlaybackStartInfo, PlaybackStartInfo,
RepeatMode, RepeatMode,
} from "@jellyfin/sdk/lib/generated-client"; } from "@jellyfin/sdk/lib/generated-client";
@@ -14,15 +15,15 @@ import { router, useGlobalSearchParams, useNavigation } from "expo-router";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Alert, Platform, View } from "react-native"; import { Alert, View } from "react-native";
import { useAnimatedReaction, useSharedValue } from "react-native-reanimated"; import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BITRATES } from "@/components/BitrateSelector"; import { BITRATES } from "@/components/BitrateSelector";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { Loader } from "@/components/Loader"; import { Loader } from "@/components/Loader";
import { Controls } from "@/components/video-player/controls/Controls"; import { Controls } from "@/components/video-player/controls/Controls";
import { getDownloadedFileUrl } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache"; import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { useWebSocket } from "@/hooks/useWebsockets"; import { useWebSocket } from "@/hooks/useWebsockets";
import { VlcPlayerView } from "@/modules"; import { VlcPlayerView } from "@/modules";
@@ -32,15 +33,18 @@ import type {
ProgressUpdatePayload, ProgressUpdatePayload,
VlcPlayerViewRef, VlcPlayerViewRef,
} from "@/modules/VlcPlayer.types"; } from "@/modules/VlcPlayer.types";
import { useDownload } from "@/providers/DownloadProvider";
import { DownloadedItem } from "@/providers/Downloads/types";
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 { 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 { storage } from "@/utils/mmkv";
import generateDeviceProfile from "@/utils/profiles/native";
import { msToTicks, ticksToSeconds } from "@/utils/time"; import { msToTicks, ticksToSeconds } from "@/utils/time";
const downloadProvider = require("@/providers/DownloadProvider");
const IGNORE_SAFE_AREAS_KEY = "video_player_ignore_safe_areas";
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);
@@ -50,12 +54,11 @@ export default function page() {
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false); const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
const [showControls, _setShowControls] = useState(true); const [showControls, _setShowControls] = useState(true);
const [aspectRatio, setAspectRatio] = useState< const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(() => {
"default" | "16:9" | "4:3" | "1:1" | "21:9" // Load persisted state from storage
>("default"); const saved = storage.getBoolean(IGNORE_SAFE_AREAS_KEY);
const [scaleFactor, setScaleFactor] = useState< return saved ?? false;
1.0 | 1.1 | 1.2 | 1.3 | 1.4 | 1.5 | 1.6 | 1.7 | 1.8 | 1.9 | 2.0 });
>(1.0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isBuffering, setIsBuffering] = useState(true); const [isBuffering, setIsBuffering] = useState(true);
@@ -65,11 +68,9 @@ export default function page() {
const progress = useSharedValue(0); const progress = useSharedValue(0);
const isSeeking = useSharedValue(false); const isSeeking = useSharedValue(false);
const cacheProgress = useSharedValue(0); const cacheProgress = useSharedValue(0);
const VolumeManager = Platform.isTV const VolumeManager = require("react-native-volume-manager");
? null
: require("react-native-volume-manager");
const downloadUtils = useDownload(); const getDownloadedItem = downloadProvider.useDownload();
const revalidateProgressCache = useInvalidatePlaybackProgressCache(); const revalidateProgressCache = useInvalidatePlaybackProgressCache();
@@ -80,6 +81,11 @@ export default function page() {
lightHapticFeedback(); lightHapticFeedback();
}, []); }, []);
// Persist ignoreSafeAreas state whenever it changes
useEffect(() => {
storage.set(IGNORE_SAFE_AREAS_KEY, ignoreSafeAreas);
}, [ignoreSafeAreas]);
const { const {
itemId, itemId,
audioIndex: audioIndexStr, audioIndex: audioIndexStr,
@@ -98,10 +104,9 @@ export default function page() {
/** Playback position in ticks. */ /** Playback position in ticks. */
playbackPosition?: string; playbackPosition?: string;
}>(); }>();
const [_settings] = useSettings(); const [settings] = useSettings();
const insets = useSafeAreaInsets();
const offline = offlineStr === "true"; const offline = offlineStr === "true";
const playbackManager = usePlaybackManager();
const audioIndex = audioIndexStr const audioIndex = audioIndexStr
? Number.parseInt(audioIndexStr, 10) ? Number.parseInt(audioIndexStr, 10)
@@ -114,33 +119,27 @@ export default function page() {
: BITRATES[0].value; : BITRATES[0].value;
const [item, setItem] = useState<BaseItemDto | null>(null); const [item, setItem] = useState<BaseItemDto | null>(null);
const [downloadedItem, setDownloadedItem] = useState<DownloadedItem | null>(
null,
);
const [itemStatus, setItemStatus] = useState({ const [itemStatus, setItemStatus] = useState({
isLoading: true, isLoading: true,
isError: false, isError: false,
}); });
/** Gets the initial playback position from the URL. */ /** Gets the initial playback position from the URL or the item's user data. */
const getInitialPlaybackTicks = useCallback((): number => { const getInitialPlaybackTicks = useCallback((): number => {
if (playbackPositionFromUrl) { if (playbackPositionFromUrl) {
return Number.parseInt(playbackPositionFromUrl, 10); return Number.parseInt(playbackPositionFromUrl, 10);
} }
return item?.UserData?.PlaybackPositionTicks ?? 0; return item?.UserData?.PlaybackPositionTicks ?? 0;
}, [playbackPositionFromUrl]); }, [playbackPositionFromUrl, item]);
useEffect(() => { useEffect(() => {
const fetchItemData = async () => { const fetchItemData = async () => {
setItemStatus({ isLoading: true, isError: false }); setItemStatus({ isLoading: true, isError: false });
try { try {
let fetchedItem: BaseItemDto | null = null; let fetchedItem: BaseItemDto | null = null;
if (offline && !Platform.isTV) { if (offline) {
const data = downloadUtils.getDownloadedItemById(itemId); const data = await getDownloadedItem.getDownloadedItem(itemId);
if (data) { if (data) fetchedItem = data.item as BaseItemDto;
fetchedItem = data.item as BaseItemDto;
setDownloadedItem(data);
}
} else { } else {
const res = await getUserLibraryApi(api!).getItem({ const res = await getUserLibraryApi(api!).getItem({
itemId, itemId,
@@ -176,20 +175,17 @@ export default function page() {
useEffect(() => { useEffect(() => {
const fetchStreamData = async () => { const fetchStreamData = async () => {
setStreamStatus({ isLoading: true, isError: false }); setStreamStatus({ isLoading: true, isError: false });
const native = await generateDeviceProfile();
try { try {
let result: Stream | null = null; let result: Stream | null = null;
if (offline && downloadedItem && downloadedItem.mediaSource) { if (offline) {
const url = downloadedItem.videoFilePath; const data = await getDownloadedItem.getDownloadedItem(itemId);
if (!data?.mediaSource) return;
const url = await getDownloadedFileUrl(data.item.Id!);
if (item) { if (item) {
result = { result = { mediaSource: data.mediaSource, sessionId: "", url };
mediaSource: downloadedItem.mediaSource,
sessionId: "",
url: url,
};
} }
} else { } else {
const native = generateDeviceProfile();
const transcoding = generateDeviceProfile({ transcode: true });
const res = await getStreamUrl({ const res = await getStreamUrl({
api, api,
item, item,
@@ -199,7 +195,7 @@ export default function page() {
maxStreamingBitrate: bitrateValue, maxStreamingBitrate: bitrateValue,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
subtitleStreamIndex: subtitleIndex, subtitleStreamIndex: subtitleIndex,
deviceProfile: bitrateValue ? transcoding : native, deviceProfile: native,
}); });
if (!res) return; if (!res) return;
const { mediaSource, sessionId, url } = res; const { mediaSource, sessionId, url } = res;
@@ -220,39 +216,26 @@ export default function page() {
} }
}; };
fetchStreamData(); fetchStreamData();
}, [ }, [itemId, mediaSourceId, bitrateValue, api, item, user?.Id]);
itemId,
mediaSourceId,
bitrateValue,
api,
item,
user?.Id,
downloadedItem,
]);
useEffect(() => { useEffect(() => {
if (!stream || !api) return; if (!stream) return;
const reportPlaybackStart = async () => { const reportPlaybackStart = async () => {
await getPlaystateApi(api).reportPlaybackStart({ await getPlaystateApi(api!).reportPlaybackStart({
playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo, playbackStartInfo: currentPlayStateInfo() as PlaybackStartInfo,
}); });
}; };
reportPlaybackStart(); reportPlaybackStart();
}, [stream, api]); }, [stream]);
const togglePlay = async () => { const togglePlay = async () => {
lightHapticFeedback(); lightHapticFeedback();
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
if (isPlaying) { if (isPlaying) {
await videoRef.current?.pause(); await videoRef.current?.pause();
playbackManager.reportPlaybackProgress( reportPlaybackProgress();
item?.Id!,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
} else { } else {
videoRef.current?.play(); videoRef.current?.play();
await getPlaystateApi(api!).reportPlaybackStart({ await getPlaystateApi(api!).reportPlaybackStart({
@@ -262,6 +245,7 @@ export default function page() {
}; };
const reportPlaybackStopped = useCallback(async () => { const reportPlaybackStopped = useCallback(async () => {
if (offline) return;
const currentTimeInTicks = msToTicks(progress.get()); const currentTimeInTicks = msToTicks(progress.get());
await getPlaystateApi(api!).onPlaybackStopped({ await getPlaystateApi(api!).onPlaybackStopped({
itemId: item?.Id!, itemId: item?.Id!,
@@ -269,6 +253,8 @@ export default function page() {
positionTicks: currentTimeInTicks, positionTicks: currentTimeInTicks,
playSessionId: stream?.sessionId!, playSessionId: stream?.sessionId!,
}); });
revalidateProgressCache();
}, [ }, [
api, api,
item, item,
@@ -280,15 +266,10 @@ export default function page() {
]); ]);
const stop = useCallback(() => { const stop = useCallback(() => {
// Update URL with final playback position before stopping
router.setParams({
playbackPosition: msToTicks(progress.get()).toString(),
});
reportPlaybackStopped(); reportPlaybackStopped();
setIsPlaybackStopped(true); setIsPlaybackStopped(true);
videoRef.current?.stop(); videoRef.current?.stop();
revalidateProgressCache(); }, [videoRef, reportPlaybackStopped]);
}, [videoRef, reportPlaybackStopped, progress]);
useEffect(() => { useEffect(() => {
const beforeRemoveListener = navigation.addListener("beforeRemove", stop); const beforeRemoveListener = navigation.addListener("beforeRemove", stop);
@@ -297,7 +278,7 @@ export default function page() {
}; };
}, [navigation, stop]); }, [navigation, stop]);
const currentPlayStateInfo = useCallback(() => { const currentPlayStateInfo = () => {
if (!stream) return; if (!stream) return;
return { return {
itemId: item?.Id!, itemId: item?.Id!,
@@ -313,32 +294,7 @@ export default function page() {
repeatMode: RepeatMode.RepeatNone, repeatMode: RepeatMode.RepeatNone,
playbackOrder: PlaybackOrder.Default, playbackOrder: PlaybackOrder.Default,
}; };
}, [ };
stream,
item?.Id,
audioIndex,
subtitleIndex,
mediaSourceId,
progress,
isPlaying,
isMuted,
]);
const lastUrlUpdateTime = useSharedValue(0);
const wasJustSeeking = useSharedValue(false);
const URL_UPDATE_INTERVAL = 30000; // Update URL every 30 seconds instead of every second
// Track when seeking ends to update URL immediately
useAnimatedReaction(
() => isSeeking.get(),
(currentSeeking, previousSeeking) => {
if (previousSeeking && !currentSeeking) {
// Seeking just ended
wasJustSeeking.value = true;
}
},
[],
);
const onProgress = useCallback( const onProgress = useCallback(
async (data: ProgressUpdatePayload) => { async (data: ProgressUpdatePayload) => {
@@ -351,31 +307,15 @@ export default function page() {
progress.set(currentTime); progress.set(currentTime);
// Update URL immediately after seeking, or every 30 seconds during normal playback // Update the playback position in the URL.
const now = Date.now(); router.setParams({
const shouldUpdateUrl = wasJustSeeking.get(); playbackPosition: msToTicks(currentTime).toString(),
wasJustSeeking.value = false; });
if ( if (offline) return;
shouldUpdateUrl || if (!item?.Id || !stream) return;
now - lastUrlUpdateTime.get() > URL_UPDATE_INTERVAL
) {
router.setParams({
playbackPosition: msToTicks(currentTime).toString(),
});
lastUrlUpdateTime.value = now;
}
if (!item?.Id) return; reportPlaybackProgress();
playbackManager.reportPlaybackProgress(
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}, },
[ [
item?.Id, item?.Id,
@@ -395,14 +335,30 @@ export default function page() {
setIsPipStarted(pipStarted); 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,
]);
/** Gets the initial playback position in seconds. */ /** Gets the initial playback position in seconds. */
const startPosition = useMemo(() => { const startPosition = useMemo(() => {
if (offline) return 0;
return ticksToSeconds(getInitialPlaybackTicks()); return ticksToSeconds(getInitialPlaybackTicks());
}, [getInitialPlaybackTicks]); }, [offline, getInitialPlaybackTicks]);
const volumeUpCb = useCallback(async () => { const volumeUpCb = useCallback(async () => {
if (Platform.isTV) return;
try { try {
const { volume: currentVolume } = await VolumeManager.getVolume(); const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.min(currentVolume + 0.1, 1.0); const newVolume = Math.min(currentVolume + 0.1, 1.0);
@@ -415,8 +371,6 @@ export default function page() {
const [previousVolume, setPreviousVolume] = useState<number | null>(null); const [previousVolume, setPreviousVolume] = useState<number | null>(null);
const toggleMuteCb = useCallback(async () => { const toggleMuteCb = useCallback(async () => {
if (Platform.isTV) return;
try { try {
const { volume: currentVolume } = await VolumeManager.getVolume(); const { volume: currentVolume } = await VolumeManager.getVolume();
const currentVolumePercent = currentVolume * 100; const currentVolumePercent = currentVolume * 100;
@@ -437,10 +391,7 @@ export default function page() {
console.error("Error toggling mute:", error); console.error("Error toggling mute:", error);
} }
}, [previousVolume]); }, [previousVolume]);
const volumeDownCb = useCallback(async () => { const volumeDownCb = useCallback(async () => {
if (Platform.isTV) return;
try { try {
const { volume: currentVolume } = await VolumeManager.getVolume(); const { volume: currentVolume } = await VolumeManager.getVolume();
const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10% const newVolume = Math.max(currentVolume - 0.1, 0); // Decrease by 10%
@@ -457,8 +408,6 @@ export default function page() {
}, []); }, []);
const setVolumeCb = useCallback(async (newVolume: number) => { const setVolumeCb = useCallback(async (newVolume: number) => {
if (Platform.isTV) return;
try { try {
const clampedVolume = Math.max(0, Math.min(newVolume, 100)); const clampedVolume = Math.max(0, Math.min(newVolume, 100));
console.log("Setting volume to", clampedVolume); console.log("Setting volume to", clampedVolume);
@@ -484,33 +433,15 @@ export default function page() {
const { state, isBuffering, isPlaying } = e.nativeEvent; const { state, isBuffering, isPlaying } = e.nativeEvent;
if (state === "Playing") { if (state === "Playing") {
setIsPlaying(true); setIsPlaying(true);
if (item?.Id) { reportPlaybackProgress();
playbackManager.reportPlaybackProgress( await activateKeepAwakeAsync();
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await activateKeepAwakeAsync();
return; return;
} }
if (state === "Paused") { if (state === "Paused") {
setIsPlaying(false); setIsPlaying(false);
if (item?.Id) { reportPlaybackProgress();
playbackManager.reportPlaybackProgress( await deactivateKeepAwake();
item.Id,
msToTicks(progress.get()),
{
AudioStreamIndex: audioIndex ?? -1,
SubtitleStreamIndex: subtitleIndex ?? -1,
},
);
}
if (!Platform.isTV) await deactivateKeepAwake();
return; return;
} }
@@ -521,7 +452,7 @@ export default function page() {
setIsBuffering(true); setIsBuffering(true);
} }
}, },
[playbackManager, item?.Id, progress], [reportPlaybackProgress],
); );
const allAudio = const allAudio =
@@ -539,29 +470,25 @@ export default function page() {
.filter((sub: any) => sub.DeliveryMethod === "External") .filter((sub: any) => sub.DeliveryMethod === "External")
.map((sub: any) => ({ .map((sub: any) => ({
name: sub.DisplayTitle, name: sub.DisplayTitle,
DeliveryUrl: offline ? sub.DeliveryUrl : api?.basePath + sub.DeliveryUrl, DeliveryUrl: api?.basePath + sub.DeliveryUrl,
})); }));
/** The text based subtitle tracks */
const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream); const textSubs = allSubs.filter((sub) => sub.IsTextSubtitleStream);
/** The user chosen subtitle track from the server */
const chosenSubtitleTrack = allSubs.find( const chosenSubtitleTrack = allSubs.find(
(sub) => sub.Index === subtitleIndex, (sub) => sub.Index === subtitleIndex,
); );
/** The user chosen audio track from the server */
const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex); const chosenAudioTrack = allAudio.find((audio) => audio.Index === audioIndex);
/** Whether the stream we're playing is not transcoding*/
const notTranscoding = !stream?.mediaSource.TranscodingUrl; const notTranscoding = !stream?.mediaSource.TranscodingUrl;
/** The initial options to pass to the VLC Player */ const initOptions = [`--sub-text-scale=${settings.subtitleSize}`];
const initOptions = [``];
if ( if (
chosenSubtitleTrack && chosenSubtitleTrack &&
(notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream) (notTranscoding || chosenSubtitleTrack.IsTextSubtitleStream)
) { ) {
// If not transcoding, we can the index as normal.
// If transcoding, we need to reverse the text based subtitles, because VLC reverses the HLS subtitles.
const finalIndex = notTranscoding const finalIndex = notTranscoding
? allSubs.indexOf(chosenSubtitleTrack) ? allSubs.indexOf(chosenSubtitleTrack)
: [...textSubs].reverse().indexOf(chosenSubtitleTrack); : textSubs.indexOf(chosenSubtitleTrack);
initOptions.push(`--sub-track=${finalIndex}`); initOptions.push(`--sub-track=${finalIndex}`);
} }
@@ -577,66 +504,7 @@ export default function page() {
return () => setIsMounted(false); return () => setIsMounted(false);
}, []); }, []);
// Memoize video ref functions to prevent unnecessary re-renders if (itemStatus.isLoading || streamStatus.isLoading) {
const startPictureInPicture = useMemo(
() => videoRef.current?.startPictureInPicture,
[isVideoLoaded],
);
const play = useMemo(
() => videoRef.current?.play || (() => {}),
[isVideoLoaded],
);
const pause = useMemo(
() => videoRef.current?.pause || (() => {}),
[isVideoLoaded],
);
const seek = useMemo(
() => videoRef.current?.seekTo || (() => {}),
[isVideoLoaded],
);
const getAudioTracks = useMemo(
() => videoRef.current?.getAudioTracks,
[isVideoLoaded],
);
const getSubtitleTracks = useMemo(
() => videoRef.current?.getSubtitleTracks,
[isVideoLoaded],
);
const setSubtitleTrack = useMemo(
() => videoRef.current?.setSubtitleTrack,
[isVideoLoaded],
);
const setSubtitleURL = useMemo(
() => videoRef.current?.setSubtitleURL,
[isVideoLoaded],
);
const setAudioTrack = useMemo(
() => videoRef.current?.setAudioTrack,
[isVideoLoaded],
);
const setVideoAspectRatio = useMemo(
() => videoRef.current?.setVideoAspectRatio,
[isVideoLoaded],
);
const setVideoScaleFactor = useMemo(
() => videoRef.current?.setVideoScaleFactor,
[isVideoLoaded],
);
console.log("Debug: component render"); // Uncomment to debug re-renders
// Show error UI first, before checking loading/missingdata
if (itemStatus.isError || streamStatus.isError) {
return (
<View className='w-screen h-screen flex flex-col items-center justify-center bg-black'>
<Text className='text-white'>{t("player.error")}</Text>
</View>
);
}
// Then show loader while either side is still fetching or data isnt present
if (itemStatus.isLoading || streamStatus.isLoading || !item || !stream) {
// …loader UI…
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 />
@@ -652,14 +520,7 @@ export default function page() {
); );
return ( return (
<View <View style={{ flex: 1, backgroundColor: "black" }}>
style={{
flex: 1,
backgroundColor: "black",
height: "100%",
width: "100%",
}}
>
<View <View
style={{ style={{
display: "flex", display: "flex",
@@ -668,6 +529,8 @@ 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
@@ -675,7 +538,7 @@ export default function page() {
source={{ source={{
uri: stream?.url || "", uri: stream?.url || "",
autoplay: true, autoplay: true,
isNetwork: !offline, isNetwork: true,
startPosition, startPosition,
externalSubtitles, externalSubtitles,
initOptions, initOptions,
@@ -698,7 +561,7 @@ export default function page() {
}} }}
/> />
</View> </View>
{!isPipStarted && isMounted === true && item && ( {videoRef.current && !isPipStarted && isMounted === true && item ? (
<Controls <Controls
mediaSource={stream?.mediaSource} mediaSource={stream?.mediaSource}
item={item} item={item}
@@ -711,27 +574,23 @@ export default function page() {
isBuffering={isBuffering} isBuffering={isBuffering}
showControls={showControls} showControls={showControls}
setShowControls={setShowControls} setShowControls={setShowControls}
setIgnoreSafeAreas={setIgnoreSafeAreas}
ignoreSafeAreas={ignoreSafeAreas}
isVideoLoaded={isVideoLoaded} isVideoLoaded={isVideoLoaded}
startPictureInPicture={startPictureInPicture} startPictureInPicture={videoRef?.current?.startPictureInPicture}
play={play} play={videoRef.current?.play}
pause={pause} pause={videoRef.current?.pause}
seek={seek} seek={videoRef.current?.seekTo}
enableTrickplay={true} enableTrickplay={true}
getAudioTracks={getAudioTracks} getAudioTracks={videoRef.current?.getAudioTracks}
getSubtitleTracks={getSubtitleTracks} getSubtitleTracks={videoRef.current?.getSubtitleTracks}
offline={offline} offline={offline}
setSubtitleTrack={setSubtitleTrack} setSubtitleTrack={videoRef.current.setSubtitleTrack}
setSubtitleURL={setSubtitleURL} setSubtitleURL={videoRef.current.setSubtitleURL}
setAudioTrack={setAudioTrack} setAudioTrack={videoRef.current.setAudioTrack}
setVideoAspectRatio={setVideoAspectRatio}
setVideoScaleFactor={setVideoScaleFactor}
aspectRatio={aspectRatio}
scaleFactor={scaleFactor}
setAspectRatio={setAspectRatio}
setScaleFactor={setScaleFactor}
isVlc isVlc
/> />
)} ) : null}
</View> </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.

View File

@@ -1,6 +1,7 @@
import "@/augmentations"; import "@/augmentations";
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 { Platform } from "react-native"; import { Platform } from "react-native";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { DownloadProvider } from "@/providers/DownloadProvider"; import { DownloadProvider } from "@/providers/DownloadProvider";
@@ -10,6 +11,7 @@ import {
getTokenFromStorage, getTokenFromStorage,
JellyfinProvider, JellyfinProvider,
} from "@/providers/JellyfinProvider"; } from "@/providers/JellyfinProvider";
import { JobQueueProvider } from "@/providers/JobQueueProvider";
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider"; import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
import { WebSocketProvider } from "@/providers/WebSocketProvider"; import { WebSocketProvider } from "@/providers/WebSocketProvider";
import { type Settings, useSettings } from "@/utils/atoms/settings"; import { type Settings, useSettings } from "@/utils/atoms/settings";
@@ -25,6 +27,7 @@ import {
writeToLog, writeToLog,
} from "@/utils/log"; } from "@/utils/log";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { cancelJobById, getAllJobsByDeviceId } from "@/utils/optimize-server";
const BackGroundDownloader = !Platform.isTV const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader") ? require("@kesha-antonov/react-native-background-downloader")
@@ -33,10 +36,6 @@ const BackGroundDownloader = !Platform.isTV
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
? require("expo-background-fetch")
: null;
import * as Device from "expo-device"; import * as Device from "expo-device";
import * as FileSystem from "expo-file-system"; import * as FileSystem from "expo-file-system";
@@ -46,7 +45,7 @@ import { router, Stack, useSegments } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import * as ScreenOrientation from "@/packages/expo-screen-orientation"; import * as ScreenOrientation from "@/packages/expo-screen-orientation";
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null; import * as TaskManager from "expo-task-manager";
import { getLocales } from "expo-localization"; import { getLocales } from "expo-localization";
import { Provider as JotaiProvider } from "jotai"; import { Provider as JotaiProvider } from "jotai";
@@ -88,9 +87,9 @@ SplashScreen.setOptions({
}); });
function useNotificationObserver() { function useNotificationObserver() {
useEffect(() => { if (Platform.isTV) return;
if (Platform.isTV) return;
useEffect(() => {
let isMounted = true; let isMounted = true;
function redirect(notification: typeof Notifications.Notification) { function redirect(notification: typeof Notifications.Notification) {
@@ -127,7 +126,9 @@ if (!Platform.isTV) {
console.log("TaskManager ~ sessions trigger"); console.log("TaskManager ~ sessions trigger");
const api = store.get(apiAtom); const api = store.get(apiAtom);
if (api === null || api === undefined) return; if (api === null || api === undefined) {
return { value: null };
}
const response = await getSessionApi(api).getSessions({ const response = await getSessionApi(api).getSessions({
activeWithinSeconds: 360, activeWithinSeconds: 360,
@@ -136,30 +137,99 @@ if (!Platform.isTV) {
const result = response.data.filter((s) => s.NowPlayingItem); const result = response.data.filter((s) => s.NowPlayingItem);
Notifications.setBadgeCountAsync(result.length); Notifications.setBadgeCountAsync(result.length);
return BackgroundFetch.BackgroundFetchResult.NewData; return { value: "success" };
}); });
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
console.log("TaskManager ~ trigger"); console.log("TaskManager ~ trigger");
const now = Date.now();
const settingsData = storage.getString("settings"); const settingsData = storage.getString("settings");
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData; if (!settingsData) return { value: null };
const settings: Partial<Settings> = JSON.parse(settingsData); const settings: Partial<Settings> = JSON.parse(settingsData);
const url = settings?.optimizedVersionsServerUrl;
if (!settings?.autoDownload) if (!settings?.autoDownload || !url) return { value: null };
return BackgroundFetch.BackgroundFetchResult.NoData;
const token = getTokenFromStorage(); const token = getTokenFromStorage();
const deviceId = getOrSetDeviceId(); const deviceId = getOrSetDeviceId();
const baseDirectory = FileSystem.documentDirectory; const baseDirectory = FileSystem.documentDirectory;
if (!token || !deviceId || !baseDirectory) if (!token || !deviceId || !baseDirectory) return { value: null };
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,
});
});
}
}
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 { value: "success" };
}); });
} }
@@ -235,51 +305,51 @@ function Layout() {
); );
}, [settings?.preferedLanguage, i18n]); }, [settings?.preferedLanguage, i18n]);
useNotificationObserver(); if (!Platform.isTV) {
useNotificationObserver();
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>(); const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
const notificationListener = useRef<EventSubscription>(); const notificationListener = useRef<EventSubscription>();
const responseListener = useRef<EventSubscription>(); const responseListener = useRef<EventSubscription>();
useEffect(() => { useEffect(() => {
if (!Platform.isTV && expoPushToken && api && user) { if (expoPushToken && api && user) {
api api
?.post("/Streamyfin/device", { ?.post("/Streamyfin/device", {
token: expoPushToken.data, token: expoPushToken.data,
deviceId: getOrSetDeviceId(), deviceId: getOrSetDeviceId(),
userId: user.Id, userId: user.Id,
}) })
.then((_) => console.log("Posted expo push token")) .then((_) => console.log("Posted expo push token"))
.catch((_) => .catch((_) =>
writeErrorLog("Failed to push expo push token to plugin"), writeErrorLog("Failed to push expo push token to plugin"),
); );
} else console.log("No token available"); } else console.log("No token available");
}, [api, expoPushToken, user]); }, [api, expoPushToken, user]);
async function registerNotifications() { async function registerNotifications() {
if (Platform.OS === "android") { if (Platform.OS === "android") {
console.log("Setting android notification channel 'default'"); console.log("Setting android notification channel 'default'");
await Notifications?.setNotificationChannelAsync("default", { await Notifications?.setNotificationChannelAsync("default", {
name: "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));
}
} }
await checkAndRequestPermissions(); useEffect(() => {
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
await registerBackgroundFetchAsyncSessions();
}
// only create push token for real devices (pointless for emulators)
if (Device.isDevice) {
Notifications?.getExpoPushTokenAsync()
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
.catch((reason: any) => console.log("Failed to get token", reason));
}
}
useEffect(() => {
if (!Platform.isTV) {
registerNotifications(); registerNotifications();
notificationListener.current = notificationListener.current =
@@ -297,10 +367,12 @@ function Layout() {
(response: NotificationResponse) => { (response: NotificationResponse) => {
// Currently the notifications supported by the plugin will send data for deep links. // Currently the notifications supported by the plugin will send data for deep links.
const { title, data } = response.notification.request.content; const { title, data } = response.notification.request.content;
writeDebugLog( writeDebugLog(
`Notification ${title} opened`, `Notification ${title} opened`,
response.notification.request.content, response.notification.request.content,
); );
if (data && Object.keys(data).length > 0) { if (data && Object.keys(data).length > 0) {
const type = data?.type?.toLower?.(); const type = data?.type?.toLower?.();
const itemId = data?.id; const itemId = data?.id;
@@ -313,10 +385,12 @@ function Layout() {
// We just clicked a notification for an individual episode. // We just clicked a notification for an individual episode.
if (itemId) { if (itemId) {
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`); router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
// summarized season notification for multiple episodes. Bring them to series season }
} else { // summarized season notification for multiple episodes. Bring them to series season
else {
const seriesId = data.seriesId; const seriesId = data.seriesId;
const seasonIndex = data.seasonIndex; const seasonIndex = data.seasonIndex;
if (seasonIndex) { if (seasonIndex) {
router.push( router.push(
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`, `/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
@@ -341,50 +415,48 @@ function Layout() {
responseListener.current, responseListener.current,
); );
}; };
} }, []);
}, [user, api]);
useEffect(() => {
if (Platform.isTV) {
return;
}
if (segments.includes("direct-player" as never)) {
if (
!settings.followDeviceOrientation &&
settings.defaultVideoOrientation
) {
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
}
return;
}
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [
settings.followDeviceOrientation,
settings.defaultVideoOrientation,
segments,
]);
useEffect(() => { useEffect(() => {
if (Platform.isTV) { const subscription = AppState.addEventListener(
return; "change",
} (nextAppState) => {
if (
if (segments.includes("direct-player" as never)) { appState.current.match(/inactive|background/) &&
if ( nextAppState === "active"
!settings.followDeviceOrientation && ) {
settings.defaultVideoOrientation BackGroundDownloader.checkForExistingDownloads();
) { }
ScreenOrientation.lockAsync(settings.defaultVideoOrientation); },
} );
return;
}
if (settings.followDeviceOrientation === true) {
ScreenOrientation.unlockAsync();
} else {
ScreenOrientation.lockAsync(
ScreenOrientation.OrientationLock.PORTRAIT_UP,
);
}
}, [
settings.followDeviceOrientation,
settings.defaultVideoOrientation,
segments,
]);
useEffect(() => {
if (Platform.isTV) {
return;
}
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (
appState.current.match(/inactive|background/) &&
nextAppState === "active"
) {
BackGroundDownloader.checkForExistingDownloads();
}
});
BackGroundDownloader.checkForExistingDownloads(); BackGroundDownloader.checkForExistingDownloads();
@@ -395,62 +467,85 @@ function Layout() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<JellyfinProvider> <JobQueueProvider>
<PlaySettingsProvider> <JellyfinProvider>
<LogProvider> <PlaySettingsProvider>
<WebSocketProvider> <LogProvider>
<DownloadProvider> <WebSocketProvider>
<BottomSheetModalProvider> <DownloadProvider>
<SystemBars style='light' hidden={false} /> <BottomSheetModalProvider>
<ThemeProvider value={DarkTheme}> <SystemBars style='light' hidden={false} />
<Stack initialRouteName='(auth)/(tabs)'> <ThemeProvider value={DarkTheme}>
<Stack.Screen <Stack initialRouteName='(auth)/(tabs)'>
name='(auth)/(tabs)' <Stack.Screen
options={{ name='(auth)/(tabs)'
headerShown: false, options={{
title: "", headerShown: false,
header: () => null, title: "",
header: () => null,
}}
/>
<Stack.Screen
name='(auth)/player'
options={{
headerShown: false,
title: "",
header: () => null,
}}
/>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}} }}
closeButton
/> />
<Stack.Screen </ThemeProvider>
name='(auth)/player' </BottomSheetModalProvider>
options={{ </DownloadProvider>
headerShown: false, </WebSocketProvider>
title: "", </LogProvider>
header: () => null, </PlaySettingsProvider>
}} </JellyfinProvider>
/> </JobQueueProvider>
<Stack.Screen
name='login'
options={{
headerShown: true,
title: "",
headerTransparent: true,
}}
/>
<Stack.Screen name='+not-found' />
</Stack>
<Toaster
duration={4000}
toastOptions={{
style: {
backgroundColor: "#262626",
borderColor: "#363639",
borderWidth: 1,
},
titleStyle: {
color: "white",
},
}}
closeButton
/>
</ThemeProvider>
</BottomSheetModalProvider>
</DownloadProvider>
</WebSocketProvider>
</LogProvider>
</PlaySettingsProvider>
</JellyfinProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
function _saveDownloadedItemInfo(item: BaseItemDto) {
try {
const downloadedItems = storage.getString("downloadedItems");
const items: BaseItemDto[] = downloadedItems
? JSON.parse(downloadedItems)
: [];
const existingItemIndex = items.findIndex((i) => i.Id === item.Id);
if (existingItemIndex !== -1) {
items[existingItemIndex] = item;
} else {
items.push(item);
}
storage.set("downloadedItems", JSON.stringify(items));
} catch (error) {
writeToLog("ERROR", "Failed to save downloaded item information:", error);
console.error("Failed to save downloaded item information:", error);
}
}

View File

@@ -291,7 +291,7 @@ const Login: React.FC = () => {
marginLeft: -23, marginLeft: -23,
marginBottom: -20, marginBottom: -20,
}} }}
source={require("@/assets/images/icon-ios-plain.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'>

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

BIN
assets/images/icon-mono.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -7,27 +7,15 @@ declare module "react-native-mmkv" {
} }
} }
// Add the augmentation methods directly to the MMKV prototype
// This follows the recommended pattern while adding the helper methods your app uses
MMKV.prototype.get = function <T>(key: string): T | undefined { MMKV.prototype.get = function <T>(key: string): T | undefined {
try { const serializedItem = this.getString(key);
const serializedItem = this.getString(key); return serializedItem ? JSON.parse(serializedItem) : undefined;
if (!serializedItem) return undefined;
return JSON.parse(serializedItem);
} catch (error) {
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
return undefined;
}
}; };
MMKV.prototype.setAny = function (key: string, value: any | undefined): void { MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
try { if (value === undefined) {
if (value === undefined) { this.delete(key);
this.delete(key); } else {
} else { this.set(key, JSON.stringify(value));
this.set(key, JSON.stringify(value));
}
} catch (error) {
console.warn(`Failed to set MMKV value for key "${key}":`, error);
} }
}; };

View File

@@ -1,14 +1,14 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", "$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
"files": { "files": {
"includes": [ "includes": [
"**/*", "**/*",
"!node_modules", "!node_modules/**",
"!ios", "!ios/**",
"!android", "!android/**",
"!Streamyfin.app", "!Streamyfin.app/**",
"!utils/jellyseerr", "!utils/jellyseerr/**",
"!.expo" "!.expo/**"
] ]
}, },
"linter": { "linter": {
@@ -24,9 +24,7 @@
"noForEach": "off" "noForEach": "off"
}, },
"recommended": true, "recommended": true,
"correctness": { "correctness": { "useExhaustiveDependencies": "off" },
"useExhaustiveDependencies": "off"
},
"suspicious": { "suspicious": {
"noExplicitAny": "off", "noExplicitAny": "off",
"noArrayIndexKey": "off" "noArrayIndexKey": "off"

508
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; import type { 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; const DropdownMenu = require("zeego/dropdown-menu");
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Text } from "./common/Text"; import { Text } from "./common/Text";
@@ -19,8 +19,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
const isTv = Platform.isTV;
const audioStreams = useMemo( const audioStreams = useMemo(
() => source?.MediaStreams?.filter((x) => x.Type === "Audio"), () => source?.MediaStreams?.filter((x) => x.Type === "Audio"),
[source], [source],
@@ -33,8 +31,6 @@ export const AudioTrackSelector: React.FC<Props> = ({
const { t } = useTranslation(); const { t } = useTranslation();
if (isTv) return null;
return ( return (
<View <View
className='flex shrink' className='flex shrink'

View File

@@ -1,6 +1,6 @@
import { Platform, TouchableOpacity, View } from "react-native"; import { TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = require("zeego/dropdown-menu");
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -60,26 +60,22 @@ export const BitrateSelector: React.FC<Props> = ({
inverted, inverted,
...props ...props
}) => { }) => {
const isTv = Platform.isTV;
const sorted = useMemo(() => { const sorted = useMemo(() => {
if (inverted) if (inverted)
return BITRATES.slice().sort( return BITRATES.sort(
(a, b) => (a, b) =>
(a.value || Number.POSITIVE_INFINITY) - (a.value || Number.POSITIVE_INFINITY) -
(b.value || Number.POSITIVE_INFINITY), (b.value || Number.POSITIVE_INFINITY),
); );
return BITRATES.slice().sort( return BITRATES.sort(
(a, b) => (a, b) =>
(b.value || Number.POSITIVE_INFINITY) - (b.value || Number.POSITIVE_INFINITY) -
(a.value || Number.POSITIVE_INFINITY), (a.value || Number.POSITIVE_INFINITY),
); );
}, [inverted]); }, []);
const { t } = useTranslation(); const { t } = useTranslation();
if (isTv) return null;
return ( return (
<View <View
className='flex shrink' className='flex shrink'

View File

@@ -1,6 +1,6 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { Platform } from "react-native"; import { Platform, type ViewProps } from "react-native";
import GoogleCast, { import GoogleCast, {
CastButton, CastButton,
CastContext, CastContext,
@@ -11,6 +11,12 @@ import GoogleCast, {
} from "react-native-google-cast"; } from "react-native-google-cast";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
interface Props extends ViewProps {
width?: number;
height?: number;
background?: "blur" | "transparent";
}
export function Chromecast({ export function Chromecast({
width = 48, width = 48,
height = 48, height = 48,
@@ -38,7 +44,11 @@ export function Chromecast({
// Android requires the cast button to be present for startDiscovery to work // Android requires the cast button to be present for startDiscovery to work
const AndroidCastButton = useCallback( const AndroidCastButton = useCallback(
() => () =>
Platform.OS === "android" ? <CastButton tintColor='transparent' /> : null, Platform.OS === "android" ? (
<CastButton tintColor='transparent' />
) : (
<></>
),
[Platform.OS], [Platform.OS],
); );

View File

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

View File

@@ -6,7 +6,6 @@ import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { apiAtom } from "@/providers/JellyfinProvider"; import { apiAtom } from "@/providers/JellyfinProvider";
import { ProgressBar } from "./common/ProgressBar";
import { WatchedIndicator } from "./WatchedIndicator"; import { WatchedIndicator } from "./WatchedIndicator";
type ContinueWatchingPosterProps = { type ContinueWatchingPosterProps = {
@@ -63,6 +62,18 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`; return `${api?.basePath}/Items/${item.Id}/Images/Primary?fillHeight=389&quality=80`;
}, [item]); }, [item]);
const progress = useMemo(() => {
if (item.Type === "Program") {
const startDate = new Date(item.StartDate || "");
const endDate = new Date(item.EndDate || "");
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [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' />;
@@ -90,8 +101,22 @@ const ContinueWatchingPoster: React.FC<ContinueWatchingPosterProps> = ({
</View> </View>
)} )}
</View> </View>
{!item.UserData?.Played && <WatchedIndicator item={item} />} {!progress && <WatchedIndicator item={item} />}
<ProgressBar item={item} /> {progress > 0 && (
<>
<View
className={
"absolute w-100 bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
}
/>
<View
style={{
width: `${progress}%`,
}}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
/>
</>
)}
</View> </View>
); );
}; };

View File

@@ -9,20 +9,21 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { type Href, router } from "expo-router"; import { type Href, router, useFocusEffect } from "expo-router";
import { t } from "i18next"; import { t } from "i18next";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Platform, Switch, View, type ViewProps } from "react-native"; import { Alert, Platform, View, type ViewProps } from "react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { queueAtom } from "@/utils/atoms/queue"; import { queueAtom } from "@/utils/atoms/queue";
import { useSettings } from "@/utils/atoms/settings"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings"; import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
import { getDownloadUrl } from "@/utils/jellyfin/media/getDownloadUrl"; import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
import { saveDownloadItemInfoToDiskTmp } from "@/utils/optimize-server";
import download from "@/utils/profiles/download";
import { AudioTrackSelector } from "./AudioTrackSelector"; import { AudioTrackSelector } from "./AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "./BitrateSelector"; import { type Bitrate, BitrateSelector } from "./BitrateSelector";
import { Button } from "./Button"; import { Button } from "./Button";
@@ -33,13 +34,6 @@ import ProgressCircle from "./ProgressCircle";
import { RoundButton } from "./RoundButton"; import { RoundButton } from "./RoundButton";
import { SubtitleTrackSelector } from "./SubtitleTrackSelector"; import { SubtitleTrackSelector } from "./SubtitleTrackSelector";
export type SelectedOptions = {
bitrate: Bitrate;
mediaSource: MediaSourceInfo | undefined;
audioIndex: number | undefined;
subtitleIndex: number;
};
interface DownloadProps extends ViewProps { interface DownloadProps extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
MissingDownloadIconComponent: () => React.ReactElement; MissingDownloadIconComponent: () => React.ReactElement;
@@ -60,29 +54,33 @@ export const DownloadItems: React.FC<DownloadProps> = ({
}) => { }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [user] = useAtom(userAtom); const [user] = useAtom(userAtom);
const [queue, _setQueue] = useAtom(queueAtom); const [queue, setQueue] = useAtom(queueAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const [downloadUnwatchedOnly, setDownloadUnwatchedOnly] = useState(false);
const { processes, startBackgroundDownload, getDownloadedItems } = const { processes, startBackgroundDownload, downloadedFiles } = useDownload();
useDownload(); //const { startRemuxing } = useRemuxHlsToMp4();
const downloadedFiles = getDownloadedItems();
const [selectedOptions, setSelectedOptions] = useState< const [selectedMediaSource, setSelectedMediaSource] = useState<
SelectedOptions | undefined MediaSourceInfo | undefined | null
>(undefined); >(undefined);
const [selectedAudioStream, setSelectedAudioStream] = useState<number>(-1);
const { const [selectedSubtitleStream, setSelectedSubtitleStream] =
defaultAudioIndex, useState<number>(0);
defaultBitrate, const [maxBitrate, setMaxBitrate] = useState<Bitrate>(
defaultMediaSource, settings?.defaultBitrate ?? {
defaultSubtitleIndex, key: "Max",
} = useDefaultPlaySettings(items[0], settings); value: undefined,
},
);
const userCanDownload = useMemo( const userCanDownload = useMemo(
() => user?.Policy?.EnableContentDownloading, () => user?.Policy?.EnableContentDownloading,
[user], [user],
); );
const usingOptimizedServer = useMemo(
() => settings?.downloadMethod === DownloadMethod.Optimized,
[settings],
);
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
@@ -104,28 +102,6 @@ export const DownloadItems: React.FC<DownloadProps> = ({
[items, downloadedFiles], [items, downloadedFiles],
); );
// Initialize selectedOptions with default values
useEffect(() => {
setSelectedOptions(() => ({
bitrate: defaultBitrate,
mediaSource: defaultMediaSource,
subtitleIndex: defaultSubtitleIndex ?? -1,
audioIndex: defaultAudioIndex,
}));
}, [
defaultAudioIndex,
defaultBitrate,
defaultSubtitleIndex,
defaultMediaSource,
]);
const itemsToDownload = useMemo(() => {
if (downloadUnwatchedOnly) {
return itemsNotDownloaded.filter((item) => !item.UserData?.Played);
}
return itemsNotDownloaded;
}, [itemsNotDownloaded, downloadUnwatchedOnly]);
const allItemsDownloaded = useMemo(() => { const allItemsDownloaded = useMemo(() => {
if (items.length === 0) return false; if (items.length === 0) return false;
return itemsNotDownloaded.length === 0; return itemsNotDownloaded.length === 0;
@@ -168,98 +144,99 @@ export const DownloadItems: React.FC<DownloadProps> = ({
); );
}; };
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsNotDownloaded.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsNotDownloaded);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [
queue,
setQueue,
itemsNotDownloaded,
usingOptimizedServer,
userCanDownload,
maxBitrate,
selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
]);
const initiateDownload = useCallback( const initiateDownload = useCallback(
async (...items: BaseItemDto[]) => { async (...items: BaseItemDto[]) => {
if ( if (
!api || !api ||
!user?.Id || !user?.Id ||
items.some((p) => !p.Id) || items.some((p) => !p.Id) ||
(itemsNotDownloaded.length === 1 && !selectedOptions?.mediaSource?.Id) (itemsNotDownloaded.length === 1 && !selectedMediaSource?.Id)
) { ) {
throw new Error( throw new Error(
"DownloadItem ~ initiateDownload: No api or user or item", "DownloadItem ~ initiateDownload: No api or user or item",
); );
} }
const downloadDetailsPromises = items.map(async (item) => { let mediaSource = selectedMediaSource;
const { mediaSource, audioIndex, subtitleIndex } = let audioIndex: number | undefined = selectedAudioStream;
itemsNotDownloaded.length > 1 let subtitleIndex: number | undefined = selectedSubtitleStream;
? getDefaultPlaySettings(item, settings!)
: {
mediaSource: selectedOptions?.mediaSource,
audioIndex: selectedOptions?.audioIndex,
subtitleIndex: selectedOptions?.subtitleIndex,
};
const downloadDetails = await getDownloadUrl({ for (const item of items) {
if (itemsNotDownloaded.length > 1) {
const defaults = getDefaultPlaySettings(item, settings!);
mediaSource = defaults.mediaSource;
audioIndex = defaults.audioIndex;
subtitleIndex = defaults.subtitleIndex;
}
const res = await getStreamUrl({
api, api,
item, item,
userId: user.Id!, startTimeTicks: 0,
mediaSource: mediaSource!, userId: user?.Id,
audioStreamIndex: audioIndex ?? -1, audioStreamIndex: audioIndex,
subtitleStreamIndex: subtitleIndex ?? -1, maxStreamingBitrate: maxBitrate.value,
maxBitrate: selectedOptions?.bitrate || defaultBitrate, mediaSourceId: mediaSource?.Id,
deviceId: api.deviceInfo.id, subtitleStreamIndex: subtitleIndex,
deviceProfile: download,
download: true,
// deviceId: mediaSource?.Id,
}); });
return { if (!res) {
url: downloadDetails?.url,
item,
mediaSource: downloadDetails?.mediaSource,
};
});
const downloadDetails = await Promise.all(downloadDetailsPromises);
for (const { url, item, mediaSource } of downloadDetails) {
if (!url) {
Alert.alert( Alert.alert(
t("home.downloads.something_went_wrong"), t("home.downloads.something_went_wrong"),
t("home.downloads.could_not_get_stream_url_from_jellyfin"), t("home.downloads.could_not_get_stream_url_from_jellyfin"),
); );
continue; continue;
} }
if (!mediaSource) {
console.error(`Could not get download URL for ${item.Name}`); const { mediaSource: source, url } = res;
toast.error(
t("Could not get download URL for {{itemName}}", { if (!url || !source) throw new Error("No url");
itemName: item.Name,
}), saveDownloadItemInfoToDiskTmp(item, source, url);
); await startBackgroundDownload(url, item, source, maxBitrate);
continue;
}
await startBackgroundDownload(
url,
item,
mediaSource,
selectedOptions?.bitrate || defaultBitrate,
);
} }
}, },
[ [
api, api,
user?.Id, user?.Id,
itemsNotDownloaded, itemsNotDownloaded,
selectedOptions, selectedMediaSource,
selectedAudioStream,
selectedSubtitleStream,
settings, settings,
defaultBitrate, maxBitrate,
usingOptimizedServer,
startBackgroundDownload, startBackgroundDownload,
], ],
); );
const acceptDownloadOptions = useCallback(() => {
if (userCanDownload === true) {
if (itemsToDownload.some((i) => !i.Id)) {
throw new Error("No item id");
}
closeModal();
initiateDownload(...itemsToDownload);
} else {
toast.error(
t("home.downloads.toasts.you_are_not_allowed_to_download_files"),
);
}
}, [closeModal, initiateDownload, itemsToDownload, userCanDownload]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: BottomSheetBackdropProps) => ( (props: BottomSheetBackdropProps) => (
<BottomSheetBackdrop <BottomSheetBackdrop
@@ -270,6 +247,19 @@ export const DownloadItems: React.FC<DownloadProps> = ({
), ),
[], [],
); );
useFocusEffect(
useCallback(() => {
if (!settings) return;
if (itemsNotDownloaded.length !== 1) return;
const { bitrate, mediaSource, audioIndex, subtitleIndex } =
getDefaultPlaySettings(items[0], settings);
setSelectedMediaSource(mediaSource ?? undefined);
setSelectedAudioStream(audioIndex ?? 0);
setSelectedSubtitleStream(subtitleIndex ?? -1);
setMaxBitrate(bitrate);
}, [items, itemsNotDownloaded, settings]),
);
const renderButtonContent = () => { const renderButtonContent = () => {
if (processes.length > 0 && itemsProcesses.length > 0) { if (processes.length > 0 && itemsProcesses.length > 0) {
@@ -337,78 +327,40 @@ export const DownloadItems: React.FC<DownloadProps> = ({
<Text className='text-neutral-300'> <Text className='text-neutral-300'>
{subtitle || {subtitle ||
t("item_card.download.download_x_item", { t("item_card.download.download_x_item", {
item_count: itemsToDownload.length, item_count: itemsNotDownloaded.length,
})} })}
</Text> </Text>
</View> </View>
<View className='flex flex-col space-y-2 w-full items-start'> <View className='flex flex-col space-y-2 w-full items-start'>
<BitrateSelector <BitrateSelector
inverted inverted
onChange={(val) => onChange={setMaxBitrate}
setSelectedOptions( selected={maxBitrate}
(prev) => prev && { ...prev, bitrate: val },
)
}
selected={selectedOptions?.bitrate}
/> />
{itemsNotDownloaded.length > 1 && (
<View className='flex flex-row items-center justify-between w-full py-2'>
<Text>{t("item_card.download.download_unwatched_only")}</Text>
<Switch
onValueChange={setDownloadUnwatchedOnly}
value={downloadUnwatchedOnly}
/>
</View>
)}
{itemsNotDownloaded.length === 1 && ( {itemsNotDownloaded.length === 1 && (
<View> <>
<MediaSourceSelector <MediaSourceSelector
item={items[0]} item={items[0]}
onChange={(val) => onChange={setSelectedMediaSource}
setSelectedOptions( selected={selectedMediaSource}
(prev) =>
prev && {
...prev,
mediaSource: val,
},
)
}
selected={selectedOptions?.mediaSource}
/> />
{selectedOptions?.mediaSource && ( {selectedMediaSource && (
<View className='flex flex-col space-y-2'> <View className='flex flex-col space-y-2'>
<AudioTrackSelector <AudioTrackSelector
source={selectedOptions.mediaSource} source={selectedMediaSource}
onChange={(val) => { onChange={setSelectedAudioStream}
setSelectedOptions( selected={selectedAudioStream}
(prev) =>
prev && {
...prev,
audioIndex: val,
},
);
}}
selected={selectedOptions.audioIndex}
/> />
<SubtitleTrackSelector <SubtitleTrackSelector
source={selectedOptions.mediaSource} source={selectedMediaSource}
onChange={(val) => { onChange={setSelectedSubtitleStream}
setSelectedOptions( selected={selectedSubtitleStream}
(prev) =>
prev && {
...prev,
subtitleIndex: val,
},
);
}}
selected={selectedOptions.subtitleIndex}
/> />
</View> </View>
)} )}
</View> </>
)} )}
</View> </View>
<Button <Button
className='mt-auto' className='mt-auto'
onPress={acceptDownloadOptions} onPress={acceptDownloadOptions}
@@ -416,6 +368,13 @@ export const DownloadItems: React.FC<DownloadProps> = ({
> >
{t("item_card.download.download_button")} {t("item_card.download.download_button")}
</Button> </Button>
<View className='opacity-70 text-center w-full flex items-center'>
<Text className='text-xs'>
{usingOptimizedServer
? t("item_card.download.using_optimized_server")
: t("item_card.download.using_default_method")}
</Text>
</View>
</View> </View>
</BottomSheetView> </BottomSheetView>
</BottomSheetModal> </BottomSheetModal>

View File

@@ -6,7 +6,7 @@ import { Image } from "expo-image";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Platform, View } from "react-native"; import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AudioTrackSelector } from "@/components/AudioTrackSelector"; import { AudioTrackSelector } from "@/components/AudioTrackSelector";
import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector"; import { type Bitrate, BitrateSelector } from "@/components/BitrateSelector";
@@ -36,7 +36,7 @@ import { MediaSourceSelector } from "./MediaSourceSelector";
import { MoreMoviesWithActor } from "./MoreMoviesWithActor"; import { MoreMoviesWithActor } from "./MoreMoviesWithActor";
import { PlayInRemoteSessionButton } from "./PlayInRemoteSession"; import { PlayInRemoteSessionButton } from "./PlayInRemoteSession";
const Chromecast = !Platform.isTV ? require("./Chromecast") : null; const Chromecast = require("./Chromecast");
export type SelectedOptions = { export type SelectedOptions = {
bitrate: Bitrate; bitrate: Bitrate;
@@ -45,13 +45,8 @@ export type SelectedOptions = {
subtitleIndex: number; subtitleIndex: number;
}; };
interface ItemContentProps { export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
item: BaseItemDto; ({ item }) => {
isOffline: boolean;
}
export const ItemContent: React.FC<ItemContentProps> = React.memo(
({ item, isOffline }) => {
const [api] = useAtom(apiAtom); const [api] = useAtom(apiAtom);
const [settings] = useSettings(); const [settings] = useSettings();
const { orientation } = useOrientation(); const { orientation } = useOrientation();
@@ -73,16 +68,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
defaultBitrate, defaultBitrate,
defaultMediaSource, defaultMediaSource,
defaultSubtitleIndex, defaultSubtitleIndex,
} = useDefaultPlaySettings(item!, settings); } = useDefaultPlaySettings(item, settings);
const logoUrl = useMemo(
() => (item ? getLogoImageUrlById({ api, item }) : null),
[api, item],
);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
@@ -100,45 +86,40 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
]); ]);
useEffect(() => { useEffect(() => {
if (!Platform.isTV) { navigation.setOptions({
navigation.setOptions({ headerRight: () =>
headerRight: () => item && (
item && ( <View className='flex flex-row items-center space-x-2'>
<View className='flex flex-row items-center space-x-2'> <Chromecast.Chromecast background='blur' width={22} height={22} />
<Chromecast.Chromecast {item.Type !== "Program" && (
background='blur' <View className='flex flex-row items-center space-x-2'>
width={22} <DownloadSingleItem item={item} size='large' />
height={22} {user?.Policy?.IsAdministrator && (
/> <PlayInRemoteSessionButton item={item} size='large' />
{item.Type !== "Program" && ( )}
<View className='flex flex-row items-center space-x-2'>
{!Platform.isTV && (
<DownloadSingleItem item={item} size='large' />
)}
{user?.Policy?.IsAdministrator && (
<PlayInRemoteSessionButton item={item} size='large' />
)}
<PlayedStatus items={[item]} size='large' /> <PlayedStatus items={[item]} size='large' />
<AddToFavorites item={item} /> <AddToFavorites item={item} />
</View> </View>
)} )}
</View> </View>
), ),
}); });
} }, [item]);
}, [item, navigation, user]);
useEffect(() => { useEffect(() => {
if (item) { if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP)
if (orientation !== ScreenOrientation.OrientationLock.PORTRAIT_UP) setHeaderHeight(230);
setHeaderHeight(230); else if (item.Type === "Movie") setHeaderHeight(500);
else if (item.Type === "Movie") setHeaderHeight(500); else setHeaderHeight(350);
else setHeaderHeight(350); }, [item.Type, orientation]);
}
}, [item, orientation]);
if (!item || !selectedOptions) return null; const logoUrl = useMemo(() => getLogoImageUrlById({ api, item }), [item]);
const loading = useMemo(() => {
return Boolean(logoUrl && loadingLogo);
}, [loadingLogo, logoUrl]);
if (!selectedOptions) return null;
return ( return (
<View <View
@@ -179,15 +160,13 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
onLoad={() => setLoadingLogo(false)} onLoad={() => setLoadingLogo(false)}
onError={() => setLoadingLogo(false)} onError={() => setLoadingLogo(false)}
/> />
) : ( ) : null
<View />
)
} }
> >
<View className='flex flex-col bg-transparent shrink'> <View className='flex flex-col bg-transparent shrink'>
<View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'> <View className='flex flex-col px-4 w-full space-y-2 pt-2 mb-2 shrink'>
<ItemHeader item={item} className='mb-2' /> <ItemHeader item={item} className='mb-4' />
{item.Type !== "Program" && !Platform.isTV && !isOffline && ( {item.Type !== "Program" && (
<View className='flex flex-row items-center justify-start w-full h-16'> <View className='flex flex-row items-center justify-start w-full h-16'>
<BitrateSelector <BitrateSelector
className='mr-1' className='mr-1'
@@ -246,34 +225,25 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
className='grow' className='grow'
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
item={item} item={item}
isOffline={isOffline}
/> />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<SeasonEpisodesCarousel <SeasonEpisodesCarousel item={item} loading={loading} />
item={item}
loading={loading}
isOffline={isOffline}
/>
)} )}
{!isOffline && ( <ItemTechnicalDetails source={selectedOptions.mediaSource} />
<ItemTechnicalDetails source={selectedOptions.mediaSource} />
)}
<OverviewText text={item.Overview} className='px-4 mb-4' /> <OverviewText text={item.Overview} className='px-4 mb-4' />
{item.Type !== "Program" && ( {item.Type !== "Program" && (
<> <>
{item.Type === "Episode" && !isOffline && ( {item.Type === "Episode" && (
<CurrentSeries item={item} className='mb-4' /> <CurrentSeries item={item} className='mb-4' />
)} )}
{!isOffline && ( <CastAndCrew item={item} className='mb-4' loading={loading} />
<CastAndCrew item={item} className='mb-4' loading={loading} />
)}
{item.People && item.People.length > 0 && !isOffline && ( {item.People && item.People.length > 0 && (
<View className='mb-4'> <View className='mb-4'>
{item.People.slice(0, 3).map((person, idx) => ( {item.People.slice(0, 3).map((person, idx) => (
<MoreMoviesWithActor <MoreMoviesWithActor
@@ -286,7 +256,7 @@ export const ItemContent: React.FC<ItemContentProps> = React.memo(
</View> </View>
)} )}
{!isOffline && <SimilarItems itemId={item.Id} />} <SimilarItems itemId={item.Id} />
</> </>
)} )}
</View> </View>

View File

@@ -33,16 +33,16 @@ export const ItemHeader: React.FC<Props> = ({ item, ...props }) => {
<ItemActions item={item} /> <ItemActions item={item} />
</View> </View>
{item.Type === "Episode" && ( {item.Type === "Episode" && (
<View> <>
<EpisodeTitleHeader item={item} /> <EpisodeTitleHeader item={item} />
<GenreTags genres={item.Genres!} /> <GenreTags genres={item.Genres!} />
</View> </>
)} )}
{item.Type === "Movie" && ( {item.Type === "Movie" && (
<View> <>
<MoviesTitleHeader item={item} /> <MoviesTitleHeader item={item} />
<GenreTags genres={item.Genres!} /> <GenreTags genres={item.Genres!} />
</View> </>
)} )}
</View> </View>
</View> </View>

View File

@@ -21,7 +21,7 @@ interface Props {
source?: MediaSourceInfo; source?: MediaSourceInfo;
} }
export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => { export const ItemTechnicalDetails: React.FC<Props> = ({ source, ...props }) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const { t } = useTranslation(); const { t } = useTranslation();
@@ -53,7 +53,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
> >
<BottomSheetScrollView> <BottomSheetScrollView>
<View className='flex flex-col space-y-2 p-4 mb-4'> <View className='flex flex-col space-y-2 p-4 mb-4'>
<View> <View className=''>
<Text className='text-lg font-bold mb-4'> <Text className='text-lg font-bold mb-4'>
{t("item_card.video")} {t("item_card.video")}
</Text> </Text>
@@ -62,7 +62,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
</View> </View>
</View> </View>
<View> <View className=''>
<Text className='text-lg font-bold mb-2'> <Text className='text-lg font-bold mb-2'>
{t("item_card.audio")} {t("item_card.audio")}
</Text> </Text>
@@ -75,7 +75,7 @@ export const ItemTechnicalDetails: React.FC<Props> = ({ source }) => {
/> />
</View> </View>
<View> <View className=''>
<Text className='text-lg font-bold mb-2'> <Text className='text-lg font-bold mb-2'>
{t("item_card.subtitles")} {t("item_card.subtitles")}
</Text> </Text>
@@ -175,13 +175,15 @@ const AudioStreamInfo = ({ audioStreams }: { audioStreams: MediaStream[] }) => {
}; };
const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => { const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
const videoStream = useMemo(() => { if (!source) return null;
return source?.MediaStreams?.find((stream) => stream.Type === "Video") as
| MediaStream
| undefined;
}, [source?.MediaStreams]);
if (!source || !videoStream) return null; const videoStream = useMemo(() => {
return source.MediaStreams?.find(
(stream) => stream.Type === "Video",
) as MediaStream;
}, [source.MediaStreams]);
if (!videoStream) return null;
return ( return (
<View className='flex-row flex-wrap gap-2'> <View className='flex-row flex-wrap gap-2'>
@@ -219,11 +221,7 @@ const VideoStreamInfo = ({ source }: { source?: MediaSourceInfo }) => {
<Badge <Badge
variant='gray' variant='gray'
iconLeft={<Ionicons name='play-outline' size={16} color='white' />} iconLeft={<Ionicons name='play-outline' size={16} color='white' />}
text={ text={`${videoStream.AverageFrameRate?.toFixed(0)} fps`}
videoStream.AverageFrameRate != null
? `${videoStream.AverageFrameRate.toFixed(0)} fps`
: ""
}
/> />
</View> </View>
); );
@@ -236,7 +234,6 @@ const formatFileSize = (bytes?: number | null) => {
if (bytes === 0) return "0 Byte"; if (bytes === 0) return "0 Byte";
const i = Number.parseInt( const i = Number.parseInt(
Math.floor(Math.log(bytes) / Math.log(1024)).toString(), Math.floor(Math.log(bytes) / Math.log(1024)).toString(),
10,
); );
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`; return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
}; };

View File

@@ -2,7 +2,7 @@ import type {
BaseItemDto, BaseItemDto,
MediaSourceInfo, MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models"; } from "@jellyfin/sdk/lib/generated-client/models";
import { useCallback, useMemo } from "react"; import { useMemo } from "react";
import { Platform, TouchableOpacity, View } from "react-native"; import { Platform, TouchableOpacity, View } from "react-native";
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null; const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
@@ -22,31 +22,37 @@ export const MediaSourceSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
const isTv = Platform.isTV; if (Platform.isTV) return null;
const selectedName = useMemo(
() =>
item.MediaSources?.find((x) => x.Id === selected?.Id)?.MediaStreams?.find(
(x) => x.Type === "Video",
)?.DisplayTitle || "",
[item, selected],
);
const { t } = useTranslation(); const { t } = useTranslation();
const getDisplayName = useCallback((source: MediaSourceInfo) => { const commonPrefix = useMemo(() => {
const videoStream = source.MediaStreams?.find((x) => x.Type === "Video"); const mediaSources = item.MediaSources || [];
if (videoStream?.DisplayTitle) { if (!mediaSources.length) return "";
return videoStream.DisplayTitle;
let commonPrefix = "";
for (let i = 0; i < mediaSources[0].Name!.length; i++) {
const char = mediaSources[0].Name![i];
if (mediaSources.every((source) => source.Name![i] === char)) {
commonPrefix += char;
} else {
commonPrefix = commonPrefix.slice(0, -1);
break;
}
} }
return commonPrefix;
}, [item.MediaSources]);
// Fallback to source name const name = (name?: string | null) => {
if (source.Name) { return name?.replace(commonPrefix, "").toLowerCase();
return source.Name; };
}
// Last resort fallback
return `Source ${source.Id}`;
}, []);
const selectedName = useMemo(() => {
if (!selected) return "";
return getDisplayName(selected);
}, [selected, getDisplayName]);
if (isTv) return null;
return ( return (
<View <View
@@ -84,7 +90,7 @@ export const MediaSourceSelector: React.FC<Props> = ({
}} }}
> >
<DropdownMenu.ItemTitle> <DropdownMenu.ItemTitle>
{getDisplayName(source)} {`${name(source.Name)}`}
</DropdownMenu.ItemTitle> </DropdownMenu.ItemTitle>
</DropdownMenu.Item> </DropdownMenu.Item>
))} ))}

View File

@@ -38,7 +38,6 @@ import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> { interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto; item: BaseItemDto;
selectedOptions: SelectedOptions; selectedOptions: SelectedOptions;
isOffline?: boolean;
} }
const ANIMATION_DURATION = 500; const ANIMATION_DURATION = 500;
@@ -47,7 +46,6 @@ const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({ export const PlayButton: React.FC<Props> = ({
item, item,
selectedOptions, selectedOptions,
isOffline,
...props ...props
}: Props) => { }: Props) => {
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
@@ -77,7 +75,7 @@ export const PlayButton: React.FC<Props> = ({
} }
router.push(`/player/direct-player?${q}`); router.push(`/player/direct-player?${q}`);
}, },
[router, isOffline], [router],
); );
const onPress = useCallback(async () => { const onPress = useCallback(async () => {
@@ -92,8 +90,6 @@ export const PlayButton: React.FC<Props> = ({
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "", subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "", mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "", bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
playbackPosition: item.UserData?.PlaybackPositionTicks?.toString() ?? "0",
offline: isOffline ? "true" : "false",
}); });
const queryString = queryParams.toString(); const queryString = queryParams.toString();

View File

@@ -1,217 +0,0 @@
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useRouter } from "expo-router";
import { useAtom } from "jotai";
import { useCallback, useEffect } from "react";
import { TouchableOpacity, View } from "react-native";
import Animated, {
Easing,
interpolate,
interpolateColor,
useAnimatedReaction,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import { useHaptic } from "@/hooks/useHaptic";
import { itemThemeColorAtom } from "@/utils/atoms/primaryColor";
import { useSettings } from "@/utils/atoms/settings";
import { runtimeTicksToMinutes } from "@/utils/time";
import type { Button } from "./Button";
import type { SelectedOptions } from "./ItemContent";
interface Props extends React.ComponentProps<typeof Button> {
item: BaseItemDto;
selectedOptions: SelectedOptions;
}
const ANIMATION_DURATION = 500;
const MIN_PLAYBACK_WIDTH = 15;
export const PlayButton: React.FC<Props> = ({
item,
selectedOptions,
...props
}: Props) => {
const [colorAtom] = useAtom(itemThemeColorAtom);
const router = useRouter();
const startWidth = useSharedValue(0);
const targetWidth = useSharedValue(0);
const endColor = useSharedValue(colorAtom);
const startColor = useSharedValue(colorAtom);
const widthProgress = useSharedValue(0);
const colorChangeProgress = useSharedValue(0);
const [settings] = useSettings();
const lightHapticFeedback = useHaptic("light");
const goToPlayer = useCallback(
(q: string) => {
router.push(`/player/direct-player?${q}`);
},
[router],
);
const onPress = () => {
console.log("onpress");
if (!item) return;
lightHapticFeedback();
const queryParams = new URLSearchParams({
itemId: item.Id!,
audioIndex: selectedOptions.audioIndex?.toString() ?? "",
subtitleIndex: selectedOptions.subtitleIndex?.toString() ?? "",
mediaSourceId: selectedOptions.mediaSource?.Id ?? "",
bitrateValue: selectedOptions.bitrate?.value?.toString() ?? "",
});
const queryString = queryParams.toString();
goToPlayer(queryString);
return;
};
const derivedTargetWidth = useDerivedValue(() => {
if (!item || !item.RunTimeTicks) return 0;
const userData = item.UserData;
if (userData?.PlaybackPositionTicks) {
return userData.PlaybackPositionTicks > 0
? Math.max(
(userData.PlaybackPositionTicks / item.RunTimeTicks) * 100,
MIN_PLAYBACK_WIDTH,
)
: 0;
}
return 0;
}, [item]);
useAnimatedReaction(
() => derivedTargetWidth.value,
(newWidth) => {
targetWidth.value = newWidth;
widthProgress.value = 0;
widthProgress.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: Easing.bezier(0.7, 0, 0.3, 1.0),
});
},
[item],
);
useAnimatedReaction(
() => colorAtom,
(newColor) => {
endColor.value = newColor;
colorChangeProgress.value = 0;
colorChangeProgress.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: Easing.bezier(0.9, 0, 0.31, 0.99),
});
},
[colorAtom],
);
useEffect(() => {
const timeout_2 = setTimeout(() => {
startColor.value = colorAtom;
startWidth.value = targetWidth.value;
}, ANIMATION_DURATION);
return () => {
clearTimeout(timeout_2);
};
}, [colorAtom, item]);
/**
* ANIMATED STYLES
*/
const animatedAverageStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary],
),
}));
const animatedPrimaryStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.primary, endColor.value.primary],
),
}));
const animatedWidthStyle = useAnimatedStyle(() => ({
width: `${interpolate(
widthProgress.value,
[0, 1],
[startWidth.value, targetWidth.value],
)}%`,
}));
const animatedTextStyle = useAnimatedStyle(() => ({
color: interpolateColor(
colorChangeProgress.value,
[0, 1],
[startColor.value.text, endColor.value.text],
),
}));
/**
* *********************
*/
return (
<TouchableOpacity
accessibilityLabel='Play button'
accessibilityHint='Tap to play the media'
onPress={onPress}
className={"relative"}
{...props}
>
<View className='absolute w-full h-full top-0 left-0 rounded-xl z-10 overflow-hidden'>
<Animated.View
style={[
animatedPrimaryStyle,
animatedWidthStyle,
{
height: "100%",
},
]}
/>
</View>
<Animated.View
style={[animatedAverageStyle, { opacity: 0.5 }]}
className='absolute w-full h-full top-0 left-0 rounded-xl'
/>
<View
style={{
borderWidth: 1,
borderColor: colorAtom.primary,
borderStyle: "solid",
}}
className='flex flex-row items-center justify-center bg-transparent rounded-xl z-20 h-12 w-full '
>
<View className='flex flex-row items-center space-x-2'>
<Animated.Text style={[animatedTextStyle, { fontWeight: "bold" }]}>
{runtimeTicksToMinutes(item?.RunTimeTicks)}
</Animated.Text>
<Animated.Text style={animatedTextStyle}>
<Ionicons name='play-circle' size={24} />
</Animated.Text>
{settings?.openInVLC && (
<Animated.Text style={animatedTextStyle}>
<MaterialCommunityIcons
name='vlc'
size={18}
color={animatedTextStyle.color}
/>
</Animated.Text>
)}
</View>
</View>
</TouchableOpacity>
);
};

View File

@@ -1,4 +1,5 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import type React from "react"; import type React from "react";
import { View, type ViewProps } from "react-native"; import { View, type ViewProps } from "react-native";
import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed"; import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
@@ -6,13 +7,44 @@ import { RoundButton } from "./RoundButton";
interface Props extends ViewProps { interface Props extends ViewProps {
items: BaseItemDto[]; items: BaseItemDto[];
isOffline?: boolean;
size?: "default" | "large"; size?: "default" | "large";
} }
export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => { export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
const queryClient = useQueryClient();
const _invalidateQueries = () => {
items.forEach((item) => {
queryClient.invalidateQueries({
queryKey: ["item", item.Id],
});
});
queryClient.invalidateQueries({
queryKey: ["resumeItems"],
});
queryClient.invalidateQueries({
queryKey: ["continueWatching"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp-all"],
});
queryClient.invalidateQueries({
queryKey: ["nextUp"],
});
queryClient.invalidateQueries({
queryKey: ["episodes"],
});
queryClient.invalidateQueries({
queryKey: ["seasons"],
});
queryClient.invalidateQueries({
queryKey: ["home"],
});
};
const allPlayed = items.every((item) => item.UserData?.Played); const allPlayed = items.every((item) => item.UserData?.Played);
const toggle = useMarkAsPlayed(items);
const markAsPlayedStatus = useMarkAsPlayed(items);
return ( return (
<View {...props}> <View {...props}>
@@ -20,7 +52,8 @@ export const PlayedStatus: React.FC<Props> = ({ items, ...props }) => {
fillColor={allPlayed ? "primary" : undefined} fillColor={allPlayed ? "primary" : undefined}
icon={allPlayed ? "checkmark" : "checkmark"} icon={allPlayed ? "checkmark" : "checkmark"}
onPress={async () => { onPress={async () => {
await toggle(!allPlayed); console.log(allPlayed);
await markAsPlayedStatus(!allPlayed);
}} }}
size={props.size} size={props.size}
/> />

View File

@@ -20,7 +20,7 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
selected, selected,
...props ...props
}) => { }) => {
const { t } = useTranslation(); if (Platform.isTV) return null;
const subtitleStreams = useMemo(() => { const subtitleStreams = useMemo(() => {
return source?.MediaStreams?.filter((x) => x.Type === "Subtitle"); return source?.MediaStreams?.filter((x) => x.Type === "Subtitle");
}, [source]); }, [source]);
@@ -30,7 +30,9 @@ export const SubtitleTrackSelector: React.FC<Props> = ({
[subtitleStreams, selected], [subtitleStreams, selected],
); );
if (Platform.isTV || subtitleStreams?.length === 0) return null; if (subtitleStreams?.length === 0) return null;
const { t } = useTranslation();
return ( return (
<View <View

View File

@@ -34,17 +34,14 @@ const Dropdown = <T,>({
multiple = false, multiple = false,
...props ...props
}: PropsWithChildren<Props<T> & ViewProps>) => { }: PropsWithChildren<Props<T> & ViewProps>) => {
const isTv = Platform.isTV; if (Platform.isTV) return null;
const [selected, setSelected] = useState<T[]>(); const [selected, setSelected] = useState<T[]>();
useEffect(() => { useEffect(() => {
if (selected !== undefined) { if (selected !== undefined) {
onSelected(...selected); onSelected(...selected);
} }
}, [selected, onSelected]); }, [selected]);
if (isTv) return null;
return ( return (
<DisabledSetting disabled={disabled === true} showText={false} {...props}> <DisabledSetting disabled={disabled === true} showText={false} {...props}>

View File

@@ -1,5 +1,5 @@
import { FlashList, type FlashListProps } from "@shopify/flash-list"; import { FlashList, type FlashListProps } from "@shopify/flash-list";
import React, { useImperativeHandle, useRef } from "react"; import React, { forwardRef, useImperativeHandle, useRef } from "react";
import { View, type ViewStyle } from "react-native"; import { View, type ViewStyle } from "react-native";
import { Text } from "./Text"; import { Text } from "./Text";
@@ -19,59 +19,64 @@ interface HorizontalScrollProps<T>
keyExtractor?: (item: T, index: number) => string; keyExtractor?: (item: T, index: number) => string;
containerStyle?: ViewStyle; containerStyle?: ViewStyle;
contentContainerStyle?: ViewStyle; contentContainerStyle?: ViewStyle;
loadingContainerStyle?: ViewStyle;
height?: number; height?: number;
loading?: boolean; loading?: boolean;
extraData?: any; extraData?: any;
noItemsText?: string; noItemsText?: string;
} }
export const HorizontalScroll = <T,>( export const HorizontalScroll = forwardRef<
props: HorizontalScrollProps<T> & { HorizontalScrollRef,
ref?: React.ForwardedRef<HorizontalScrollRef>; HorizontalScrollProps<any>
}, >(
) => { <T,>(
const { {
data = [], data = [],
keyExtractor, keyExtractor,
renderItem, renderItem,
containerStyle, containerStyle,
contentContainerStyle, contentContainerStyle,
loading = false, loadingContainerStyle,
height = 164, loading = false,
extraData, height = 164,
noItemsText, extraData,
ref, noItemsText,
...restProps ...props
} = props; }: HorizontalScrollProps<T>,
ref: React.ForwardedRef<HorizontalScrollRef>,
) => {
const flashListRef = useRef<FlashList<T>>(null);
const flashListRef = useRef<FlashList<T>>(null); useImperativeHandle(ref!, () => ({
scrollToIndex: (index: number, viewOffset: number) => {
flashListRef.current?.scrollToIndex({
index,
animated: true,
viewPosition: 0,
viewOffset,
});
},
}));
useImperativeHandle(ref!, () => ({ const renderFlashListItem = ({
scrollToIndex: (index: number, viewOffset: number) => { item,
flashListRef.current?.scrollToIndex({ index,
index, }: {
animated: true, item: T;
viewPosition: 0, index: number;
viewOffset, }) => <View className='mr-2'>{renderItem(item, index)}</View>;
});
},
}));
const renderFlashListItem = ({ item, index }: { item: T; index: number }) => ( if (!data || loading) {
<View className='mr-2'>{renderItem(item, index)}</View> return (
); <View className='px-4 mb-2'>
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
</View>
);
}
if (!data || loading) {
return ( return (
<View className='px-4 mb-2'>
<View className='bg-neutral-950 h-24 w-full rounded-md mb-2' />
<View className='bg-neutral-950 h-10 w-full rounded-md mb-1' />
</View>
);
}
return (
<View style={[{ height }, containerStyle]}>
<FlashList<T> <FlashList<T>
ref={flashListRef} ref={flashListRef}
data={data} data={data}
@@ -92,8 +97,8 @@ export const HorizontalScroll = <T,>(
</Text> </Text>
</View> </View>
)} )}
{...restProps} {...props}
/> />
</View> );
); },
}; );

View File

@@ -56,7 +56,7 @@ export function InfiniteHorizontalScroll({
}; };
}); });
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({ const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey, queryKey,
queryFn, queryFn,
getNextPageParam: (lastPage, pages) => { getNextPageParam: (lastPage, pages) => {

View File

@@ -2,7 +2,7 @@ import { useRouter, useSegments } from "expo-router";
import type React from "react"; import type React from "react";
import { type PropsWithChildren, useCallback, useMemo } from "react"; import { type PropsWithChildren, useCallback, useMemo } from "react";
import { TouchableOpacity, type TouchableOpacityProps } from "react-native"; import { TouchableOpacity, type TouchableOpacityProps } from "react-native";
import * as ContextMenu from "zeego/context-menu"; import * as ContextMenu from "@/components/ContextMenu";
import { useJellyseerr } from "@/hooks/useJellyseerr"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media"; import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import { import {
@@ -60,67 +60,69 @@ export const TouchableJellyseerrRouter: React.FC<PropsWithChildren<Props>> = ({
if (from === "(home)" || from === "(search)" || from === "(libraries)") if (from === "(home)" || from === "(search)" || from === "(libraries)")
return ( return (
<ContextMenu.Root> <>
<ContextMenu.Trigger> <ContextMenu.Root>
<TouchableOpacity <ContextMenu.Trigger>
onPress={() => { <TouchableOpacity
if (!result) return; onPress={() => {
if (!result) return;
// @ts-expect-error // @ts-ignore
router.push({ router.push({
pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`, pathname: `/(auth)/(tabs)/${from}/jellyseerr/page`,
params: { params: {
...result, ...result,
mediaTitle, mediaTitle,
releaseYear, releaseYear,
canRequest, canRequest,
posterSrc, posterSrc,
mediaType, mediaType,
},
});
}}
{...props}
>
{children}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
{canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key='item-1'
onSelect={() => {
if (autoApprove) {
request();
}
}}
shouldDismissMenuOnSelect
>
<ContextMenu.ItemTitle key='item-1-title'>
Request
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon
ios={{
name: "arrow.down.to.line",
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "purple",
light: "purple",
}, },
});
}}
{...props}
>
{children}
</TouchableOpacity>
</ContextMenu.Trigger>
<ContextMenu.Content
avoidCollisions
alignOffset={0}
collisionPadding={0}
loop={false}
key={"content"}
>
<ContextMenu.Label key='label-1'>Actions</ContextMenu.Label>
{canRequest && mediaType === MediaType.MOVIE && (
<ContextMenu.Item
key='item-1'
onSelect={() => {
if (autoApprove) {
request();
}
}} }}
androidIconName='download' shouldDismissMenuOnSelect
/> >
</ContextMenu.Item> <ContextMenu.ItemTitle key='item-1-title'>
)} Request
</ContextMenu.Content> </ContextMenu.ItemTitle>
</ContextMenu.Root> <ContextMenu.ItemIcon
ios={{
name: "arrow.down.to.line",
pointSize: 18,
weight: "semibold",
scale: "medium",
hierarchicalColor: {
dark: "purple",
light: "purple",
},
}}
androidIconName='download'
/>
</ContextMenu.Item>
)}
</ContextMenu.Content>
</ContextMenu.Root>
</>
); );
}; };

View File

@@ -1,47 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import React, { useMemo } from "react";
import { View } from "react-native";
interface ProgressBarProps {
item: BaseItemDto;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({ item }) => {
const progress = useMemo(() => {
if (item.Type === "Program") {
if (!item.StartDate || !item.EndDate) {
return 0;
}
const startDate = new Date(item.StartDate);
const endDate = new Date(item.EndDate);
const now = new Date();
const total = endDate.getTime() - startDate.getTime();
if (total <= 0) {
return 0;
}
const elapsed = now.getTime() - startDate.getTime();
return (elapsed / total) * 100;
}
return item.UserData?.PlayedPercentage || 0;
}, [item]);
if (progress <= 0) {
return null;
}
return (
<>
<View
className={
"absolute bottom-0 left-0 h-1 bg-neutral-700 opacity-80 w-full"
}
/>
<View
style={{
width: `${progress}%`,
}}
className={"absolute bottom-0 left-0 h-1 bg-purple-600 w-full"}
/>
</>
);
};

View File

@@ -1,5 +1,10 @@
import { Platform, Text as RNText, type TextProps } from "react-native"; import { Platform, Text as RNText, type TextProps } from "react-native";
export function Text(props: TextProps) { import { UITextView } from "react-native-uitextview";
export function Text(
props: TextProps & {
uiTextView?: boolean;
},
) {
const { style, ...otherProps } = props; const { style, ...otherProps } = props;
if (Platform.isTV) if (Platform.isTV)
return ( return (
@@ -11,7 +16,7 @@ export function Text(props: TextProps) {
); );
return ( return (
<RNText <UITextView
allowFontScaling={false} allowFontScaling={false}
style={[{ color: "white" }, style]} style={[{ color: "white" }, style]}
{...otherProps} {...otherProps}

View File

@@ -11,17 +11,12 @@ import { useMarkAsPlayed } from "@/hooks/useMarkAsPlayed";
interface Props extends TouchableOpacityProps { interface Props extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
isOffline?: boolean;
} }
export const itemRouter = ( export const itemRouter = (
item: BaseItemDto | BaseItemPerson, item: BaseItemDto | BaseItemPerson,
from: string, from: string,
) => { ) => {
if ("CollectionType" in item && item.CollectionType === "livetv") {
return `/(auth)/(tabs)/${from}/livetv`;
}
if (item.Type === "Series") { if (item.Type === "Series") {
return `/(auth)/(tabs)/${from}/series/${item.Id}`; return `/(auth)/(tabs)/${from}/series/${item.Id}`;
} }
@@ -51,7 +46,6 @@ export const itemRouter = (
export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
item, item,
isOffline = false,
children, children,
...props ...props
}) => { }) => {
@@ -107,10 +101,7 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
<TouchableOpacity <TouchableOpacity
onLongPress={showActionSheet} onLongPress={showActionSheet}
onPress={() => { onPress={() => {
let url = itemRouter(item, from); const url = itemRouter(item, from);
if (isOffline) {
url += `&offline=true`;
}
// @ts-expect-error // @ts-expect-error
router.push(url); router.push(url);
}} }}
@@ -119,6 +110,4 @@ export const TouchableItemRouter: React.FC<PropsWithChildren<Props>> = ({
{children} {children}
</TouchableOpacity> </TouchableOpacity>
); );
return null;
}; };

View File

@@ -6,6 +6,7 @@ import { t } from "i18next";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
TouchableOpacity, TouchableOpacity,
type TouchableOpacityProps, type TouchableOpacityProps,
View, View,
@@ -14,16 +15,17 @@ import {
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { JobStatus } from "@/providers/Downloads/types"; import { DownloadMethod, useSettings } from "@/utils/atoms/settings";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import type { JobStatus } from "@/utils/optimize-server";
import { formatTimeString } from "@/utils/time"; import { formatTimeString } from "@/utils/time";
import { Button } from "../Button"; import { Button } from "../Button";
interface Props extends ViewProps {} const BackGroundDownloader = !Platform.isTV
? require("@kesha-antonov/react-native-background-downloader")
: null;
const bytesToMB = (bytes: number) => { interface Props extends ViewProps {}
return bytes / 1024 / 1024;
};
export const ActiveDownloads: React.FC<Props> = ({ ...props }) => { export const ActiveDownloads: React.FC<Props> = ({ ...props }) => {
const { processes } = useDownload(); const { processes } = useDownload();
@@ -58,18 +60,32 @@ interface DownloadCardProps extends TouchableOpacityProps {
} }
const DownloadCard = ({ process, ...props }: DownloadCardProps) => { const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
const { startDownload, removeProcess } = useDownload(); const { processes, startDownload } = useDownload();
const router = useRouter(); const router = useRouter();
const { removeProcess, setProcesses } = useDownload();
const [settings] = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const cancelJobMutation = useMutation({ const cancelJobMutation = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
if (!process) throw new Error("No active download"); if (!process) throw new Error("No active download");
removeProcess(id);
try {
const tasks = await BackGroundDownloader.checkForExistingDownloads();
for (const task of tasks) {
if (task.id === id) {
task.stop();
}
}
} finally {
await removeProcess(id);
if (settings?.downloadMethod === DownloadMethod.Optimized) {
await queryClient.refetchQueries({ queryKey: ["jobs"] });
}
}
}, },
onSuccess: () => { onSuccess: () => {
toast.success(t("home.downloads.toasts.download_cancelled")); toast.success(t("home.downloads.toasts.download_cancelled"));
queryClient.invalidateQueries({ queryKey: ["downloads"] });
}, },
onError: (e) => { onError: (e) => {
console.error(e); console.error(e);
@@ -78,14 +94,11 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
}); });
const eta = (p: JobStatus) => { const eta = (p: JobStatus) => {
if (!p.speed || p.speed <= 0 || !p.estimatedTotalSizeBytes) return null; if (!p.speed || !p.progress) return null;
const bytesRemaining = p.estimatedTotalSizeBytes - (p.bytesDownloaded || 0); const length = p?.item?.RunTimeTicks || 0;
if (bytesRemaining <= 0) return null; const timeLeft = (length - length * (p.progress / 100)) / p.speed;
return formatTimeString(timeLeft, "tick");
const secondsRemaining = bytesRemaining / p.speed;
return formatTimeString(secondsRemaining, "s");
}; };
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
@@ -98,7 +111,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden' className='relative bg-neutral-900 border border-neutral-800 rounded-2xl overflow-hidden'
{...props} {...props}
> >
{process.status === "downloading" && ( {(process.status === "optimizing" ||
process.status === "downloading") && (
<View <View
className={` className={`
bg-purple-600 h-1 absolute bottom-0 left-0 bg-purple-600 h-1 absolute bottom-0 left-0
@@ -138,10 +152,8 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
) : ( ) : (
<Text className='text-xs'>{process.progress.toFixed(0)}%</Text> <Text className='text-xs'>{process.progress.toFixed(0)}%</Text>
)} )}
{process.speed && process.speed > 0 && ( {process.speed && (
<Text className='text-xs'> <Text className='text-xs'>{process.speed?.toFixed(2)}x</Text>
{bytesToMB(process.speed).toFixed(2)} MB/s
</Text>
)} )}
{eta(process) && ( {eta(process) && (
<Text className='text-xs'> <Text className='text-xs'>
@@ -157,7 +169,7 @@ const DownloadCard = ({ process, ...props }: DownloadCardProps) => {
<TouchableOpacity <TouchableOpacity
disabled={cancelJobMutation.isPending} disabled={cancelJobMutation.isPending}
onPress={() => cancelJobMutation.mutate(process.id)} onPress={() => cancelJobMutation.mutate(process.id)}
className='ml-auto p-2 rounded-full' className='ml-auto'
> >
{cancelJobMutation.isPending ? ( {cancelJobMutation.isPending ? (
<ActivityIndicator size='small' color='white' /> <ActivityIndicator size='small' color='white' />

View File

@@ -13,8 +13,7 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
items, items,
...props ...props
}) => { }) => {
const { getDownloadedItemSize, getDownloadedItems } = useDownload(); const { downloadedFiles, getDownloadedItemSize } = useDownload();
const downloadedFiles = getDownloadedItems();
const [size, setSize] = useState<string | undefined>(); const [size, setSize] = useState<string | undefined>();
const itemIds = useMemo(() => items.map((i) => i.Id), [items]); const itemIds = useMemo(() => items.map((i) => i.Id), [items]);
@@ -40,8 +39,10 @@ export const DownloadSize: React.FC<DownloadSizeProps> = ({
}, [size]); }, [size]);
return ( return (
<Text className='text-xs text-neutral-500' {...props}> <>
{sizeText} <Text className='text-xs text-neutral-500' {...props}>
</Text> {sizeText}
</Text>
</>
); );
}; };

View File

@@ -4,13 +4,18 @@ import {
} from "@expo/react-native-action-sheet"; } from "@expo/react-native-action-sheet";
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models"; import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import type React from "react"; import type React from "react";
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { type TouchableOpacityProps, View } from "react-native"; import {
TouchableOpacity,
type TouchableOpacityProps,
View,
} from "react-native";
import { Text } from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic"; import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv";
import { runtimeTicksToSeconds } from "@/utils/time"; import { runtimeTicksToSeconds } from "@/utils/time";
import ContinueWatchingPoster from "../ContinueWatchingPoster"; import ContinueWatchingPoster from "../ContinueWatchingPoster";
@@ -18,17 +23,26 @@ interface EpisodeCardProps extends TouchableOpacityProps {
item: BaseItemDto; item: BaseItemDto;
} }
export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => { export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item, ...props }) => {
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success"); const successHapticFeedback = useHaptic("success");
const _base64Image = useMemo(() => {
return storage.getString(item.Id!);
}, [item]);
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
/** /**
* Handles deleting the file with haptic feedback. * Handles deleting the file with haptic feedback.
*/ */
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id, "Episode"); deleteFile(item.Id);
successHapticFeedback(); successHapticFeedback();
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);
@@ -59,10 +73,10 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]); }, [showActionSheetWithOptions, handleDeleteFile]);
return ( return (
<TouchableItemRouter <TouchableOpacity
item={item} onPress={handleOpenFile}
isOffline={true}
onLongPress={showActionSheet} onLongPress={showActionSheet}
key={item.Id}
className='flex flex-col mb-4' className='flex flex-col mb-4'
> >
<View className='flex flex-row items-start mb-2'> <View className='flex flex-row items-start mb-2'>
@@ -86,7 +100,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({ item }) => {
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'> <Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
{item.Overview} {item.Overview}
</Text> </Text>
</TouchableItemRouter> </TouchableOpacity>
); );
}; };

View File

@@ -7,12 +7,12 @@ import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import { Image } from "expo-image"; import { Image } from "expo-image";
import type React from "react"; import type React from "react";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { View } from "react-native"; import { TouchableOpacity, View } from "react-native";
import { DownloadSize } from "@/components/downloads/DownloadSize"; import { DownloadSize } from "@/components/downloads/DownloadSize";
import { useDownloadedFileOpener } from "@/hooks/useDownloadedFileOpener";
import { useHaptic } from "@/hooks/useHaptic";
import { useDownload } from "@/providers/DownloadProvider"; import { useDownload } from "@/providers/DownloadProvider";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import { ProgressBar } from "../common/ProgressBar";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText"; import { ItemCardText } from "../ItemCardText";
interface MovieCardProps { interface MovieCardProps {
@@ -26,10 +26,16 @@ interface MovieCardProps {
*/ */
export const MovieCard: React.FC<MovieCardProps> = ({ item }) => { export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
const { deleteFile } = useDownload(); const { deleteFile } = useDownload();
const { openFile } = useDownloadedFileOpener();
const { showActionSheetWithOptions } = useActionSheet(); const { showActionSheetWithOptions } = useActionSheet();
const successHapticFeedback = useHaptic("success");
const handleOpenFile = useCallback(() => {
openFile(item);
}, [item, openFile]);
const base64Image = useMemo(() => { const base64Image = useMemo(() => {
return storage.getString(item?.Id!); return storage.getString(item.Id!);
}, []); }, []);
/** /**
@@ -37,7 +43,8 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
*/ */
const handleDeleteFile = useCallback(() => { const handleDeleteFile = useCallback(() => {
if (item.Id) { if (item.Id) {
deleteFile(item.Id, "Movie"); deleteFile(item.Id);
successHapticFeedback();
} }
}, [deleteFile, item.Id]); }, [deleteFile, item.Id]);
@@ -67,9 +74,9 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
}, [showActionSheetWithOptions, handleDeleteFile]); }, [showActionSheetWithOptions, handleDeleteFile]);
return ( return (
<TouchableItemRouter onLongPress={showActionSheet} item={item} isOffline> <TouchableOpacity onPress={handleOpenFile} onLongPress={showActionSheet}>
{base64Image ? ( {base64Image ? (
<View className='relative w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'> <View className='w-28 aspect-[10/15] rounded-lg overflow-hidden mr-2 border border-neutral-900'>
<Image <Image
source={{ source={{
uri: `data:image/jpeg;base64,${base64Image}`, uri: `data:image/jpeg;base64,${base64Image}`,
@@ -80,24 +87,22 @@ export const MovieCard: React.FC<MovieCardProps> = ({ item }) => {
resizeMode: "cover", resizeMode: "cover",
}} }}
/> />
<ProgressBar item={item} />
</View> </View>
) : ( ) : (
<View className='relative w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'> <View className='w-28 aspect-[10/15] rounded-lg bg-neutral-900 mr-2 flex items-center justify-center'>
<Ionicons <Ionicons
name='image-outline' name='image-outline'
size={24} size={24}
color='gray' color='gray'
className='self-center mt-16' className='self-center mt-16'
/> />
<ProgressBar item={item} />
</View> </View>
)} )}
<View className='w-28'> <View className='w-28'>
<ItemCardText item={item} /> <ItemCardText item={item} />
</View> </View>
<DownloadSize items={[item]} /> <DownloadSize items={[item]} />
</TouchableItemRouter> </TouchableOpacity>
); );
}; };

View File

@@ -72,6 +72,7 @@ export const FilterSheet = <T,>({
renderItemLabel, renderItemLabel,
showSearch = true, showSearch = true,
multiple = false, multiple = false,
...props
}: Props<T>) => { }: Props<T>) => {
const bottomSheetModalRef = useRef<BottomSheetModal>(null); const bottomSheetModalRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["80%"], []); const snapPoints = useMemo(() => ["80%"], []);

View File

@@ -9,7 +9,7 @@ import { Image, Text, View } from "react-native";
import heart from "@/assets/icons/heart.fill.png"; import heart from "@/assets/icons/heart.fill.png";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { InfiniteScrollingCollectionList } from "./InfiniteScrollingCollectionList"; import { ScrollingCollectionList } from "./ScrollingCollectionList";
type FavoriteTypes = type FavoriteTypes =
| "Series" | "Series"
@@ -33,11 +33,7 @@ export const Favorites = () => {
}); });
const fetchFavoritesByType = useCallback( const fetchFavoritesByType = useCallback(
async ( async (itemType: BaseItemKind) => {
itemType: BaseItemKind,
startIndex: number = 0,
limit: number = 20,
) => {
const response = await getItemsApi(api as Api).getItems({ const response = await getItemsApi(api as Api).getItems({
userId: user?.Id, userId: user?.Id,
sortBy: ["SeriesSortName", "SortName"], sortBy: ["SeriesSortName", "SortName"],
@@ -48,19 +44,16 @@ export const Favorites = () => {
collapseBoxSetItems: false, collapseBoxSetItems: false,
excludeLocationTypes: ["Virtual"], excludeLocationTypes: ["Virtual"],
enableTotalRecordCount: false, enableTotalRecordCount: false,
startIndex: startIndex, limit: 20,
limit: limit,
includeItemTypes: [itemType], includeItemTypes: [itemType],
}); });
const items = response.data.Items || []; const items = response.data.Items || [];
// Update empty state for this specific type only for the first page // Update empty state for this specific type
if (startIndex === 0) { setEmptyState((prev) => ({
setEmptyState((prev) => ({ ...prev,
...prev, [itemType as FavoriteTypes]: items.length === 0,
[itemType as FavoriteTypes]: items.length === 0, }));
}));
}
return items; return items;
}, },
@@ -89,33 +82,27 @@ export const Favorites = () => {
}; };
const fetchFavoriteSeries = useCallback( const fetchFavoriteSeries = useCallback(
({ pageParam }: { pageParam: number }) => () => fetchFavoritesByType("Series"),
fetchFavoritesByType("Series", pageParam),
[fetchFavoritesByType], [fetchFavoritesByType],
); );
const fetchFavoriteMovies = useCallback( const fetchFavoriteMovies = useCallback(
({ pageParam }: { pageParam: number }) => () => fetchFavoritesByType("Movie"),
fetchFavoritesByType("Movie", pageParam),
[fetchFavoritesByType], [fetchFavoritesByType],
); );
const fetchFavoriteEpisodes = useCallback( const fetchFavoriteEpisodes = useCallback(
({ pageParam }: { pageParam: number }) => () => fetchFavoritesByType("Episode"),
fetchFavoritesByType("Episode", pageParam),
[fetchFavoritesByType], [fetchFavoritesByType],
); );
const fetchFavoriteVideos = useCallback( const fetchFavoriteVideos = useCallback(
({ pageParam }: { pageParam: number }) => () => fetchFavoritesByType("Video"),
fetchFavoritesByType("Video", pageParam),
[fetchFavoritesByType], [fetchFavoritesByType],
); );
const fetchFavoriteBoxsets = useCallback( const fetchFavoriteBoxsets = useCallback(
({ pageParam }: { pageParam: number }) => () => fetchFavoritesByType("BoxSet"),
fetchFavoritesByType("BoxSet", pageParam),
[fetchFavoritesByType], [fetchFavoritesByType],
); );
const fetchFavoritePlaylists = useCallback( const fetchFavoritePlaylists = useCallback(
({ pageParam }: { pageParam: number }) => () => fetchFavoritesByType("Playlist"),
fetchFavoritesByType("Playlist", pageParam),
[fetchFavoritesByType], [fetchFavoritesByType],
); );
@@ -136,38 +123,38 @@ export const Favorites = () => {
</Text> </Text>
</View> </View>
)} )}
<InfiniteScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteSeries} queryFn={fetchFavoriteSeries}
queryKey={["home", "favorites", "series"]} queryKey={["home", "favorites", "series"]}
title={t("favorites.series")} title={t("favorites.series")}
hideIfEmpty hideIfEmpty
/> />
<InfiniteScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteMovies} queryFn={fetchFavoriteMovies}
queryKey={["home", "favorites", "movies"]} queryKey={["home", "favorites", "movies"]}
title={t("favorites.movies")} title={t("favorites.movies")}
hideIfEmpty hideIfEmpty
orientation='vertical' orientation='vertical'
/> />
<InfiniteScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteEpisodes} queryFn={fetchFavoriteEpisodes}
queryKey={["home", "favorites", "episodes"]} queryKey={["home", "favorites", "episodes"]}
title={t("favorites.episodes")} title={t("favorites.episodes")}
hideIfEmpty hideIfEmpty
/> />
<InfiniteScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteVideos} queryFn={fetchFavoriteVideos}
queryKey={["home", "favorites", "videos"]} queryKey={["home", "favorites", "videos"]}
title={t("favorites.videos")} title={t("favorites.videos")}
hideIfEmpty hideIfEmpty
/> />
<InfiniteScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoriteBoxsets} queryFn={fetchFavoriteBoxsets}
queryKey={["home", "favorites", "boxsets"]} queryKey={["home", "favorites", "boxsets"]}
title={t("favorites.boxsets")} title={t("favorites.boxsets")}
hideIfEmpty hideIfEmpty
/> />
<InfiniteScrollingCollectionList <ScrollingCollectionList
queryFn={fetchFavoritePlaylists} queryFn={fetchFavoritePlaylists}
queryKey={["home", "favorites", "playlists"]} queryKey={["home", "favorites", "playlists"]}
title={t("favorites.playlists")} title={t("favorites.playlists")}

View File

@@ -1,191 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
import {
type QueryFunction,
type QueryKey,
useInfiniteQuery,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
ScrollView,
View,
type ViewProps,
} from "react-native";
import { Text } from "@/components/common/Text";
import MoviePoster from "@/components/posters/MoviePoster";
import ContinueWatchingPoster from "../ContinueWatchingPoster";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
import { ItemCardText } from "../ItemCardText";
import SeriesPoster from "../posters/SeriesPoster";
interface Props extends ViewProps {
title?: string | null;
orientation?: "horizontal" | "vertical";
disabled?: boolean;
queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[], QueryKey, number>;
hideIfEmpty?: boolean;
pageSize?: number;
}
export const InfiniteScrollingCollectionList: React.FC<Props> = ({
title,
orientation = "vertical",
disabled = false,
queryFn,
queryKey,
hideIfEmpty = false,
pageSize = 20,
...props
}) => {
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: queryKey,
queryFn: ({ pageParam = 0, ...context }) =>
queryFn({ ...context, queryKey, pageParam }),
getNextPageParam: (lastPage, allPages) => {
// If the last page has fewer items than pageSize, we've reached the end
if (lastPage.length < pageSize) {
return undefined;
}
// Otherwise, return the next start index
return allPages.length * pageSize;
},
initialPageParam: 0,
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
});
const { t } = useTranslation();
// Flatten all pages into a single array
const allItems = data?.pages.flat() || [];
if (hideIfEmpty === true && allItems.length === 0 && !isLoading) return null;
if (disabled || !title) return null;
const handleScroll = (event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 20;
// Check if we're near the end of the scroll
if (
layoutMeasurement.width + contentOffset.x >=
contentSize.width - paddingToBottom
) {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}
};
return (
<View {...props}>
<Text className='px-4 text-lg font-bold mb-2 text-neutral-100'>
{title}
</Text>
{isLoading === false && allItems.length === 0 && (
<View className='px-4'>
<Text className='text-neutral-500'>{t("home.no_items")}</Text>
</View>
)}
{isLoading ? (
<View
className={`
flex flex-row gap-2 px-4
`}
>
{[1, 2, 3].map((i) => (
<View className='w-44' key={i}>
<View className='bg-neutral-900 h-24 w-full rounded-md mb-1' />
<View className='rounded-md overflow-hidden mb-1 self-start'>
<Text
className='text-neutral-900 bg-neutral-900 rounded-md'
numberOfLines={1}
>
Nisi mollit voluptate amet.
</Text>
</View>
<View className='rounded-md overflow-hidden self-start mb-1'>
<Text
className='text-neutral-900 bg-neutral-900 text-xs rounded-md '
numberOfLines={1}
>
Lorem ipsum
</Text>
</View>
</View>
))}
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
>
<View className='px-4 flex flex-row'>
{allItems.map((item) => (
<TouchableItemRouter
item={item}
key={item.Id}
className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"}
`}
>
{item.Type === "Episode" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Episode" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Movie" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Movie" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Series" && orientation === "vertical" && (
<SeriesPoster item={item} />
)}
{item.Type === "Series" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Program" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "BoxSet" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "BoxSet" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Playlist" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Playlist" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
{item.Type === "Video" && orientation === "vertical" && (
<MoviePoster item={item} />
)}
{item.Type === "Video" && orientation === "horizontal" && (
<ContinueWatchingPoster item={item} />
)}
<ItemCardText item={item} />
</TouchableItemRouter>
))}
{/* Loading indicator for next page */}
{isFetchingNextPage && (
<View className='justify-center items-center w-16'>
<ActivityIndicator size='small' color='#6366f1' />
</View>
)}
</View>
</ScrollView>
)}
</View>
);
};

View File

@@ -154,7 +154,7 @@ const RenderItem: React.FC<{ item: BaseItemDto }> = ({ item }) => {
if (!from) return; if (!from) return;
const url = itemRouter(item, from); const url = itemRouter(item, from);
lightHapticFeedback(); lightHapticFeedback();
// @ts-expect-error // @ts-ignore
if (url) router.push(url); if (url) router.push(url);
}, [item, from]); }, [item, from]);

View File

@@ -20,7 +20,6 @@ interface Props extends ViewProps {
queryKey: QueryKey; queryKey: QueryKey;
queryFn: QueryFunction<BaseItemDto[]>; queryFn: QueryFunction<BaseItemDto[]>;
hideIfEmpty?: boolean; hideIfEmpty?: boolean;
isOffline?: boolean;
} }
export const ScrollingCollectionList: React.FC<Props> = ({ export const ScrollingCollectionList: React.FC<Props> = ({
@@ -30,7 +29,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
queryFn, queryFn,
queryKey, queryKey,
hideIfEmpty = false, hideIfEmpty = false,
isOffline = false,
...props ...props
}) => { }) => {
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -92,7 +90,6 @@ export const ScrollingCollectionList: React.FC<Props> = ({
<TouchableItemRouter <TouchableItemRouter
item={item} item={item}
key={item.Id} key={item.Id}
isOffline={isOffline}
className={`mr-2 className={`mr-2
${orientation === "horizontal" ? "w-44" : "w-28"} ${orientation === "horizontal" ? "w-44" : "w-28"}
`} `}

View File

@@ -50,8 +50,11 @@ const Fact: React.FC<{ title: string; fact?: string | null } & ViewProps> = ({
const DetailFacts: React.FC< const DetailFacts: React.FC<
{ details?: MovieDetails | TvDetails } & ViewProps { details?: MovieDetails | TvDetails } & ViewProps
> = ({ details, className, ...props }) => { > = ({ details, className, ...props }) => {
const { jellyseerrRegion: region, jellyseerrLocale: locale } = const {
useJellyseerr(); jellyseerrUser,
jellyseerrRegion: region,
jellyseerrLocale: locale,
} = useJellyseerr();
const { t } = useTranslation(); const { t } = useTranslation();
const releases = useMemo( const releases = useMemo(

View File

@@ -40,6 +40,7 @@ const ParallaxSlideShow = <T,>({
renderItem, renderItem,
keyExtractor, keyExtractor,
onEndReached, onEndReached,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => { }: PropsWithChildren<Props<T> & ViewProps>) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -139,7 +140,7 @@ const ParallaxSlideShow = <T,>({
} }
nestedScrollEnabled nestedScrollEnabled
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
//@ts-expect-error //@ts-ignore
renderItem={({ item, index }) => renderItem(item, index)} renderItem={({ item, index }) => renderItem(item, index)}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
numColumns={3} numColumns={3}

View File

@@ -38,7 +38,16 @@ const RequestModal = forwardRef<
Props & Omit<ViewProps, "id"> Props & Omit<ViewProps, "id">
>( >(
( (
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss }, {
id,
title,
requestBody,
type,
isAnime = false,
onRequested,
onDismiss,
...props
},
ref, ref,
) => { ) => {
const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr(); const { jellyseerrApi, jellyseerrUser, requestMedia } = useJellyseerr();

View File

@@ -14,22 +14,19 @@ import { studios } from "@/utils/jellyseerr/src/components/Discover/StudioSlider
interface Props { interface Props {
sliders?: DiscoverSlider[]; sliders?: DiscoverSlider[];
} }
const Discover: React.FC<Props> = ({ sliders }) => { const Discover: React.FC<Props> = ({ sliders }) => {
const hasSliders = !!sliders; if (!sliders) return;
const sortedSliders = useMemo( const sortedSliders = useMemo(
() => () =>
sortBy( sortBy(
(sliders ?? []).filter((s) => s.enabled), sliders.filter((s) => s.enabled),
"order", "order",
"asc", "asc",
), ),
[sliders], [sliders],
); );
if (!hasSliders) return null;
return ( return (
<View className='flex flex-col space-y-4 mb-8'> <View className='flex flex-col space-y-4 mb-8'>
{sortedSliders.map((slide) => { {sortedSliders.map((slide) => {
@@ -63,8 +60,6 @@ const Discover: React.FC<Props> = ({ sliders }) => {
contentContainerStyle={{ paddingBottom: 16 }} contentContainerStyle={{ paddingBottom: 16 }}
/> />
); );
default:
return null;
} }
})} })}
</View> </View>

View File

@@ -24,7 +24,7 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
[slide], [slide],
); );
const { data } = useQuery({ const { data, isFetching, isLoading } = useQuery({
queryKey: ["jellyseerr", "discover", slide.type, slide.id], queryKey: ["jellyseerr", "discover", slide.type, slide.id],
queryFn: async () => { queryFn: async () => {
return jellyseerrApi?.getGenreSliders( return jellyseerrApi?.getGenreSliders(

View File

@@ -11,7 +11,11 @@ import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces
const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => { const RequestCard: React.FC<{ request: MediaRequest }> = ({ request }) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { data: details } = useQuery({ const {
data: details,
isLoading,
isError,
} = useQuery({
queryKey: [ queryKey: [
"jellyseerr", "jellyseerr",
"detail", "detail",
@@ -53,7 +57,11 @@ const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
}) => { }) => {
const { jellyseerrApi } = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const { data: requests } = useQuery({ const {
data: requests,
isLoading,
isError,
} = useQuery({
queryKey: ["jellyseerr", "recent_requests"], queryKey: ["jellyseerr", "recent_requests"],
queryFn: async () => jellyseerrApi?.requests(), queryFn: async () => jellyseerrApi?.requests(),
enabled: !!jellyseerrApi, enabled: !!jellyseerrApi,

View File

@@ -49,9 +49,9 @@ const Slide = <T,>({
data={data} data={data}
onEndReachedThreshold={1} onEndReachedThreshold={1}
onEndReached={onEndReached} onEndReached={onEndReached}
//@ts-expect-error //@ts-ignore
renderItem={({ item, index }) => renderItem={({ item, index }) =>
item ? renderItem(item, index) : null item ? renderItem(item, index) : <></>
} }
/> />
</View> </View>

View File

@@ -32,7 +32,6 @@ const icons: Record<CollectionType, IconName> = {
boxsets: "albums", boxsets: "albums",
playlists: "list", playlists: "list",
folders: "folder", folders: "folder",
livetv: "tv",
musicvideos: "musical-notes", musicvideos: "musical-notes",
photos: "images", photos: "images",
trailers: "videocam", trailers: "videocam",

View File

@@ -82,6 +82,7 @@ const ListItemContent = ({
showArrow, showArrow,
iconAfter, iconAfter,
children, children,
...props
}: Props) => { }: Props) => {
return ( return (
<> <>

View File

@@ -1,42 +0,0 @@
import { View } from "react-native";
import { Text } from "../common/Text";
export const HourHeader = ({ height }: { height: number }) => {
const now = new Date();
const currentHour = now.getHours();
const hoursRemaining = 24 - currentHour;
const hours = generateHours(currentHour, hoursRemaining);
return (
<View
className='flex flex-row'
style={{
height,
}}
>
{hours.map((hour, index) => (
<HourCell key={index} hour={hour} />
))}
</View>
);
};
const HourCell = ({ hour }: { hour: Date }) => (
<View className='w-[200px] flex items-center justify-center bg-neutral-800'>
<Text className='text-xs text-gray-600'>
{hour.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</View>
);
const generateHours = (startHour: number, count: number): Date[] => {
const now = new Date();
return Array.from({ length: count }, (_, i) => {
const hour = new Date(now);
hour.setHours(startHour + i, 0, 0, 0);
return hour;
});
};

View File

@@ -1,96 +0,0 @@
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
import { useMemo, useRef } from "react";
import { Dimensions, View } from "react-native";
import { Text } from "../common/Text";
import { TouchableItemRouter } from "../common/TouchableItemRouter";
export const LiveTVGuideRow = ({
channel,
programs,
scrollX = 0,
isVisible = true,
}: {
channel: BaseItemDto;
programs?: BaseItemDto[] | null;
scrollX?: number;
isVisible?: boolean;
}) => {
const _positionRefs = useRef<{ [key: string]: number }>({});
const screenWidth = Dimensions.get("window").width;
const calculateWidth = (s?: string | null, e?: string | null) => {
if (!s || !e) return 0;
const start = new Date(s);
const end = new Date(e);
const duration = end.getTime() - start.getTime();
const minutes = duration / 60000;
const width = (minutes / 60) * 200;
return width;
};
const programsWithPositions = useMemo(() => {
let cumulativeWidth = 0;
return programs
?.filter((p) => p.ChannelId === channel.Id)
.map((p) => {
const width = calculateWidth(p.StartDate, p.EndDate);
const position = cumulativeWidth;
cumulativeWidth += width;
return { ...p, width, position };
});
}, [programs, channel.Id]);
const isCurrentlyLive = (program: BaseItemDto) => {
if (!program.StartDate || !program.EndDate) return false;
const now = new Date();
const start = new Date(program.StartDate);
const end = new Date(program.EndDate);
return now >= start && now <= end;
};
if (!isVisible) {
return <View style={{ height: 64 }} />;
}
return (
<View key={channel.ChannelNumber} className='flex flex-row h-16'>
{programsWithPositions?.map((p) => (
<TouchableItemRouter item={p} key={p.Id}>
<View
style={{
width: p.width,
height: "100%",
position: "absolute",
left: p.position,
backgroundColor: isCurrentlyLive(p)
? "rgba(255, 255, 255, 0.1)"
: "transparent",
}}
className='flex flex-col items-center justify-center border border-neutral-800 overflow-hidden'
>
{(() => {
return (
<View
style={{
marginLeft:
p.width > screenWidth && scrollX > p.position
? scrollX - p.position
: 0,
}}
className='px-4 self-start'
>
<Text
numberOfLines={2}
className='text-xs text-start self-start'
>
{p.Name}
</Text>
</View>
);
})()}
</View>
</TouchableItemRouter>
))}
</View>
);
};

View File

@@ -9,7 +9,7 @@ interface Props extends ViewProps {
export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => { export const MoviesTitleHeader: React.FC<Props> = ({ item, ...props }) => {
return ( return (
<View {...props}> <View {...props}>
<Text selectable className='font-bold text-2xl mb-1'> <Text uiTextView selectable className='font-bold text-2xl mb-1'>
{item?.Name} {item?.Name}
</Text> </Text>
<Text className='opacity-50'>{item?.ProductionYear}</Text> <Text className='opacity-50'>{item?.ProductionYear}</Text>

View File

@@ -38,6 +38,7 @@ const JellyseerrPoster: React.FC<Props> = ({
horizontal, horizontal,
showDownloadInfo, showDownloadInfo,
mediaRequest, mediaRequest,
...props
}) => { }) => {
const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr(); const { jellyseerrApi, getTitle, getYear, getMediaType } = useJellyseerr();
const loadingOpacity = useSharedValue(1); const loadingOpacity = useSharedValue(1);

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